diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5c684cf..d8d0726 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -39,6 +39,28 @@ jobs:
- run: npm run test
- run: npm run build
+ ingestion:
+ name: Ingestion (lint, types, tests)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: ingestion
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ cache-dependency-path: ingestion/package-lock.json
+
+ - run: npm ci
+ - run: npm run format:check
+ - run: npm run lint
+ - run: npm run typecheck
+ - run: npm run test
+ - run: npm run build
+
database:
name: Database (migrations, RLS, pgTAP, API integration)
runs-on: ubuntu-latest
diff --git a/app/src/App.tsx b/app/src/App.tsx
index f203aca..bf77598 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -6,6 +6,7 @@ import {
} from 'react-router'
import { RouterProvider } from 'react-router/dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
+import { AppLayout } from './pages/AppLayout'
import { ProfileLayout } from './pages/ProfileLayout'
import { BasicProfilePage } from './pages/BasicProfilePage'
import { EducationEditorPage } from './pages/EducationEditorPage'
@@ -14,6 +15,12 @@ import { ExperienceEditorPage } from './pages/ExperienceEditorPage'
import { ExperienceListPage } from './pages/ExperienceListPage'
import { ProfilePage } from './pages/ProfilePage'
import { SignInPage } from './pages/SignInPage'
+import { OpportunityListPage } from './pages/OpportunityListPage'
+import { OpportunityDetailPage } from './pages/OpportunityDetailPage'
+import { PrivateOpportunityDetailPage } from './pages/PrivateOpportunityDetailPage'
+import { ManualOpportunityFormPage } from './pages/ManualOpportunityFormPage'
+import { ApplicationsListPage } from './pages/ApplicationsListPage'
+import { ApplicationDetailPage } from './pages/ApplicationDetailPage'
function AuthPending() {
const { initError, loading, retryInit } = useAuth()
@@ -88,22 +95,45 @@ const router = createBrowserRouter([
errorElement: ,
children: [
{
- path: '/profile',
- element: ,
+ element: ,
children: [
- { index: true, element: },
- { path: 'basic', element: },
- { path: 'education', element: },
- { path: 'education/new', element: },
{
- path: 'education/:educationId/edit',
- element: ,
+ path: '/profile',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'basic', element: },
+ { path: 'education', element: },
+ { path: 'education/new', element: },
+ {
+ path: 'education/:educationId/edit',
+ element: ,
+ },
+ { path: 'experience', element: },
+ { path: 'experience/new', element: },
+ {
+ path: 'experience/:experienceId/edit',
+ element: ,
+ },
+ ],
},
- { path: 'experience', element: },
- { path: 'experience/new', element: },
+ { path: '/opportunities', element: },
{
- path: 'experience/:experienceId/edit',
- element: ,
+ path: '/opportunities/import',
+ element: ,
+ },
+ {
+ path: '/opportunities/manual/:privateOpportunityId',
+ element: ,
+ },
+ {
+ path: '/opportunities/:opportunityId',
+ element: ,
+ },
+ { path: '/applications', element: },
+ {
+ path: '/applications/:applicationId',
+ element: ,
},
],
},
diff --git a/app/src/lib/applicationRepository.ts b/app/src/lib/applicationRepository.ts
new file mode 100644
index 0000000..8fdb84a
--- /dev/null
+++ b/app/src/lib/applicationRepository.ts
@@ -0,0 +1,90 @@
+import { supabase } from './supabaseClient'
+import type { Application, ApplicationStatus } from './opportunityTypes'
+
+const columns =
+ 'id, user_id, shared_opportunity_id, opportunity_version_id, private_opportunity_id, private_opportunity_snapshot, status, applied_at, status_updated_at, next_action, next_action_due_at, notes, contact_note, created_at, updated_at'
+
+export interface ApplicationFieldsInput {
+ next_action?: string | null
+ next_action_due_at?: string | null
+ notes?: string | null
+ contact_note?: string | null
+}
+
+export const applicationRepository = {
+ async list(userId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .order('status_updated_at', { ascending: false })
+ .returns()
+ },
+
+ async get(id: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('id', id)
+ .maybeSingle()
+ },
+
+ async getByOpportunity(userId: string, sharedOpportunityId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .eq('shared_opportunity_id', sharedOpportunityId)
+ .maybeSingle()
+ },
+
+ async getByPrivateOpportunity(userId: string, privateOpportunityId: string) {
+ return supabase
+ .from('applications')
+ .select(columns)
+ .eq('user_id', userId)
+ .eq('private_opportunity_id', privateOpportunityId)
+ .maybeSingle()
+ },
+
+ async createForOpportunity(userId: string, opportunityVersionId: string) {
+ return supabase
+ .from('applications')
+ .insert({ user_id: userId, opportunity_version_id: opportunityVersionId })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async createForPrivateOpportunity(
+ userId: string,
+ privateOpportunityId: string,
+ ) {
+ return supabase
+ .from('applications')
+ .insert({ user_id: userId, private_opportunity_id: privateOpportunityId })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async updateStatus(id: string, status: ApplicationStatus) {
+ return supabase
+ .from('applications')
+ .update({ status })
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async updateFields(id: string, input: ApplicationFieldsInput) {
+ return supabase
+ .from('applications')
+ .update(input)
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async remove(id: string) {
+ return supabase.from('applications').delete().eq('id', id).select('id')
+ },
+}
diff --git a/app/src/lib/interviewPrepRepository.ts b/app/src/lib/interviewPrepRepository.ts
new file mode 100644
index 0000000..493c6aa
--- /dev/null
+++ b/app/src/lib/interviewPrepRepository.ts
@@ -0,0 +1,37 @@
+import { supabase } from './supabaseClient'
+import type { InterviewPrepNotes } from './opportunityTypes'
+
+const columns =
+ 'id, application_id, responsibilities_to_discuss, required_technologies, topics_to_revise, likely_questions, questions_to_ask, interview_date, interview_format, reflections'
+
+export type InterviewPrepInput = Partial<
+ Omit
+>
+
+export const interviewPrepRepository = {
+ async get(applicationId: string) {
+ return supabase
+ .from('interview_prep_notes')
+ .select(columns)
+ .eq('application_id', applicationId)
+ .maybeSingle()
+ },
+
+ async upsert(applicationId: string, input: InterviewPrepInput) {
+ const existing = await this.get(applicationId)
+ if (existing.error) return existing
+ if (existing.data) {
+ return supabase
+ .from('interview_prep_notes')
+ .update(input)
+ .eq('application_id', applicationId)
+ .select(columns)
+ .maybeSingle()
+ }
+ return supabase
+ .from('interview_prep_notes')
+ .insert({ application_id: applicationId, ...input })
+ .select(columns)
+ .maybeSingle()
+ },
+}
diff --git a/app/src/lib/opportunityRepository.ts b/app/src/lib/opportunityRepository.ts
new file mode 100644
index 0000000..184bb41
--- /dev/null
+++ b/app/src/lib/opportunityRepository.ts
@@ -0,0 +1,280 @@
+import { supabase } from './supabaseClient'
+import type {
+ OpportunitySearchRow,
+ UserOpportunityStateRow,
+} from './opportunityTypes'
+
+export type OpportunitySort =
+ | 'discovered_desc'
+ | 'posted_desc'
+ | 'deadline_asc'
+ | 'organization_asc'
+ | 'title_asc'
+
+export interface OpportunityFilters {
+ search?: string
+ opportunityKind?: string
+ employmentType?: string
+ remoteMode?: string
+ sourceKey?: string
+ lifecycleStatus?: string
+ locationText?: string
+ /** Restrict results to exactly these opportunity_ids (e.g. saved-only,
+ * applied-only). An empty array (as opposed to undefined) means "no
+ * matching opportunities" and short-circuits to zero rows without a
+ * request. */
+ includeOpportunityIds?: string[]
+ /** Exclude these opportunity_ids (e.g. hidden, unless "show hidden" is on).
+ * An empty array applies no exclusion. */
+ excludeOpportunityIds?: string[]
+}
+
+const searchColumns =
+ 'opportunity_id, opportunity_version_id, lifecycle_status, first_discovered_at, last_checked_at, source_id, source_key, source_display_name, source_listing_id, canonical_source_url, title, organization, description, location_text, country, region, city, opportunity_kind, employment_type, remote_mode, posted_at, application_deadline, application_url, version_captured_at'
+
+const stateColumns =
+ 'id, user_id, opportunity_id, saved_at, saved_opportunity_version_id, hidden_at, notes'
+
+function escapeForIlike(term: string) {
+ return term.replace(/[%_,]/g, (char) => `\\${char}`)
+}
+
+export const PAGE_SIZE = 20
+
+export const opportunityRepository = {
+ async search(
+ filters: OpportunityFilters,
+ sort: OpportunitySort,
+ page: number,
+ pageSize = PAGE_SIZE,
+ ) {
+ // Empty (but defined) include set means "nothing matches" -- e.g.
+ // saved-only with no saved opportunities. Short-circuit before hitting
+ // PostgREST so the caller sees an honest zero-row, zero-count result.
+ if (
+ filters.includeOpportunityIds &&
+ filters.includeOpportunityIds.length === 0
+ ) {
+ return {
+ data: [] as OpportunitySearchRow[],
+ count: 0,
+ error: null,
+ }
+ }
+
+ let query = supabase
+ .from('opportunity_search')
+ .select(searchColumns, { count: 'exact' })
+
+ if (filters.search?.trim()) {
+ const term = escapeForIlike(filters.search.trim())
+ query = query.or(
+ `title.ilike.%${term}%,organization.ilike.%${term}%,description.ilike.%${term}%,location_text.ilike.%${term}%`,
+ )
+ }
+ if (filters.opportunityKind)
+ query = query.eq('opportunity_kind', filters.opportunityKind)
+ if (filters.employmentType)
+ query = query.eq('employment_type', filters.employmentType)
+ if (filters.remoteMode) query = query.eq('remote_mode', filters.remoteMode)
+ if (filters.sourceKey) query = query.eq('source_key', filters.sourceKey)
+ if (filters.lifecycleStatus)
+ query = query.eq('lifecycle_status', filters.lifecycleStatus)
+ if (filters.locationText?.trim()) {
+ const term = escapeForIlike(filters.locationText.trim())
+ query = query.ilike('location_text', `%${term}%`)
+ }
+ if (filters.includeOpportunityIds) {
+ query = query.in('opportunity_id', filters.includeOpportunityIds)
+ }
+ if (
+ filters.excludeOpportunityIds &&
+ filters.excludeOpportunityIds.length > 0
+ ) {
+ query = query.not(
+ 'opportunity_id',
+ 'in',
+ `(${filters.excludeOpportunityIds.join(',')})`,
+ )
+ }
+
+ switch (sort) {
+ case 'posted_desc':
+ query = query.order('posted_at', {
+ ascending: false,
+ nullsFirst: false,
+ })
+ break
+ case 'deadline_asc':
+ query = query.order('application_deadline', {
+ ascending: true,
+ nullsFirst: false,
+ })
+ break
+ case 'organization_asc':
+ query = query.order('organization', { ascending: true })
+ break
+ case 'title_asc':
+ query = query.order('title', { ascending: true })
+ break
+ case 'discovered_desc':
+ default:
+ query = query.order('first_discovered_at', { ascending: false })
+ break
+ }
+
+ const from = page * pageSize
+ query = query.range(from, from + pageSize - 1)
+ return query.returns()
+ },
+
+ async listSources() {
+ return supabase
+ .from('sources')
+ .select('source_key, display_name')
+ .eq('enabled', true)
+ .order('display_name')
+ .returns<{ source_key: string; display_name: string }[]>()
+ },
+
+ async get(opportunityId: string) {
+ return supabase
+ .from('opportunity_search')
+ .select(searchColumns)
+ .eq('opportunity_id', opportunityId)
+ .maybeSingle()
+ },
+
+ async getVersion(opportunityVersionId: string) {
+ return supabase
+ .from('opportunity_versions')
+ .select(
+ 'id, opportunity_id, title, organization, description, location_text, remote_mode, opportunity_kind, employment_type, application_url, posted_at, application_deadline, captured_at',
+ )
+ .eq('id', opportunityVersionId)
+ .maybeSingle()
+ },
+
+ async getVersions(opportunityVersionIds: string[]) {
+ if (opportunityVersionIds.length === 0) return { data: [], error: null }
+ return supabase
+ .from('opportunity_versions')
+ .select(
+ 'id, opportunity_id, title, organization, application_url, application_deadline',
+ )
+ .in('id', opportunityVersionIds)
+ },
+
+ async listState(userId: string) {
+ return supabase
+ .from('user_opportunity_state')
+ .select(stateColumns)
+ .eq('user_id', userId)
+ .returns()
+ },
+
+ async getState(userId: string, opportunityId: string) {
+ return supabase
+ .from('user_opportunity_state')
+ .select(stateColumns)
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .maybeSingle()
+ },
+
+ async setSaved(
+ userId: string,
+ opportunityId: string,
+ opportunityVersionId: string,
+ saved: boolean,
+ ) {
+ const existing = await this.getState(userId, opportunityId)
+ if (existing.error) return existing
+ if (saved) {
+ // Plain insert/update, never .upsert(): an ON CONFLICT DO UPDATE also
+ // needs UPDATE privilege on the conflict-key columns (user_id,
+ // opportunity_id), which are deliberately not browser-writable.
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({
+ saved_at: new Date().toISOString(),
+ saved_opportunity_version_id: opportunityVersionId,
+ })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ return supabase
+ .from('user_opportunity_state')
+ .insert({
+ user_id: userId,
+ opportunity_id: opportunityId,
+ saved_at: new Date().toISOString(),
+ saved_opportunity_version_id: opportunityVersionId,
+ })
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data?.hidden_at) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({ saved_at: null, saved_opportunity_version_id: null })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .delete()
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ }
+ return { data: null, error: null }
+ },
+
+ async setHidden(userId: string, opportunityId: string, hidden: boolean) {
+ const existing = await this.getState(userId, opportunityId)
+ if (existing.error) return existing
+ if (hidden) {
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({ hidden_at: new Date().toISOString() })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ return supabase
+ .from('user_opportunity_state')
+ .insert({
+ user_id: userId,
+ opportunity_id: opportunityId,
+ hidden_at: new Date().toISOString(),
+ })
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data?.saved_at) {
+ return supabase
+ .from('user_opportunity_state')
+ .update({ hidden_at: null })
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ .select(stateColumns)
+ .maybeSingle()
+ }
+ if (existing.data) {
+ return supabase
+ .from('user_opportunity_state')
+ .delete()
+ .eq('user_id', userId)
+ .eq('opportunity_id', opportunityId)
+ }
+ return { data: null, error: null }
+ },
+}
diff --git a/app/src/lib/opportunityTypes.ts b/app/src/lib/opportunityTypes.ts
new file mode 100644
index 0000000..012613b
--- /dev/null
+++ b/app/src/lib/opportunityTypes.ts
@@ -0,0 +1,187 @@
+export type OpportunityKind =
+ | 'internship'
+ | 'working_student'
+ | 'graduate_program'
+ | 'entry_level'
+ | 'research_assistant'
+ | 'phd'
+ | 'scholarship'
+ | 'hackathon'
+ | 'fellowship'
+ | 'other'
+
+export type EmploymentType =
+ | 'full_time'
+ | 'part_time'
+ | 'contract'
+ | 'temporary'
+ | 'internship'
+ | 'volunteer'
+ | 'other'
+
+export type RemoteMode = 'onsite' | 'hybrid' | 'remote' | 'unknown'
+
+export type LifecycleStatus = 'active' | 'stale' | 'closed' | 'unknown'
+
+export type ApplicationStatus =
+ | 'preparing'
+ | 'applied'
+ | 'awaiting_response'
+ | 'interview_scheduled'
+ | 'interview_complete'
+ | 'offer'
+ | 'accepted'
+ | 'rejected'
+ | 'withdrawn'
+ | 'closed'
+
+export interface OpportunitySearchRow {
+ opportunity_id: string
+ opportunity_version_id: string
+ lifecycle_status: LifecycleStatus
+ first_discovered_at: string
+ last_checked_at: string
+ source_id: string
+ source_key: string
+ source_display_name: string
+ source_listing_id: string
+ canonical_source_url: string
+ title: string
+ organization: string
+ description: string
+ location_text: string | null
+ country: string | null
+ region: string | null
+ city: string | null
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+ version_captured_at: string
+}
+
+export interface UserOpportunityStateRow {
+ id: string
+ user_id: string
+ opportunity_id: string
+ saved_at: string | null
+ saved_opportunity_version_id: string | null
+ hidden_at: string | null
+ notes: string | null
+}
+
+export interface PrivateOpportunity {
+ id: string
+ user_id: string
+ source_url: string
+ title: string
+ organization_name: string
+ location_text: string
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ description_text: string | null
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+ dismissed_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface Application {
+ id: string
+ user_id: string
+ shared_opportunity_id: string | null
+ opportunity_version_id: string | null
+ private_opportunity_id: string | null
+ private_opportunity_snapshot: Record | null
+ status: ApplicationStatus
+ applied_at: string | null
+ status_updated_at: string
+ next_action: string | null
+ next_action_due_at: string | null
+ notes: string | null
+ contact_note: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface InterviewPrepNotes {
+ id: string
+ application_id: string
+ responsibilities_to_discuss: string | null
+ required_technologies: string | null
+ topics_to_revise: string | null
+ likely_questions: string | null
+ questions_to_ask: string | null
+ interview_date: string | null
+ interview_format: string | null
+ reflections: string | null
+}
+
+export const opportunityKindLabels: Record = {
+ internship: 'Internship',
+ working_student: 'Working student',
+ graduate_program: 'Graduate program',
+ entry_level: 'Entry level',
+ research_assistant: 'Research assistant',
+ phd: 'PhD',
+ scholarship: 'Scholarship',
+ hackathon: 'Hackathon',
+ fellowship: 'Fellowship',
+ other: 'Other',
+}
+
+export const employmentTypeLabels: Record = {
+ full_time: 'Full-time',
+ part_time: 'Part-time',
+ contract: 'Contract',
+ temporary: 'Temporary',
+ internship: 'Internship',
+ volunteer: 'Volunteer',
+ other: 'Other',
+}
+
+export const remoteModeLabels: Record = {
+ onsite: 'On-site',
+ hybrid: 'Hybrid',
+ remote: 'Remote',
+ unknown: 'Unknown',
+}
+
+export const lifecycleStatusLabels: Record = {
+ active: 'Active',
+ stale: 'Stale',
+ closed: 'Closed',
+ unknown: 'Unknown',
+}
+
+export const applicationStatusLabels: Record = {
+ preparing: 'Preparing',
+ applied: 'Applied',
+ awaiting_response: 'Awaiting response',
+ interview_scheduled: 'Interview scheduled',
+ interview_complete: 'Interview complete',
+ offer: 'Offer',
+ accepted: 'Accepted',
+ rejected: 'Rejected',
+ withdrawn: 'Withdrawn',
+ closed: 'Closed',
+}
+
+export const applicationStatusOptions = Object.keys(
+ applicationStatusLabels,
+) as ApplicationStatus[]
+
+export const opportunityKindOptions = Object.keys(
+ opportunityKindLabels,
+) as OpportunityKind[]
+
+export const employmentTypeOptions = Object.keys(
+ employmentTypeLabels,
+) as EmploymentType[]
+
+export const remoteModeOptions = Object.keys(remoteModeLabels) as RemoteMode[]
diff --git a/app/src/lib/privateOpportunityRepository.ts b/app/src/lib/privateOpportunityRepository.ts
new file mode 100644
index 0000000..a31458e
--- /dev/null
+++ b/app/src/lib/privateOpportunityRepository.ts
@@ -0,0 +1,81 @@
+import { supabase } from './supabaseClient'
+import type {
+ EmploymentType,
+ OpportunityKind,
+ PrivateOpportunity,
+ RemoteMode,
+} from './opportunityTypes'
+
+export type PrivateOpportunityInput = {
+ source_url: string
+ title: string
+ organization_name: string
+ location_text: string
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ description_text: string | null
+ posted_at: string | null
+ application_deadline: string | null
+ application_url: string | null
+}
+
+export type PrivateOpportunityUpdate = PrivateOpportunityInput
+
+const columns =
+ 'id, user_id, source_url, title, organization_name, location_text, opportunity_kind, employment_type, remote_mode, description_text, posted_at, application_deadline, application_url, dismissed_at, created_at, updated_at'
+
+export const privateOpportunityRepository = {
+ async list(userId: string, includeDismissed: boolean) {
+ let query = supabase
+ .from('private_opportunities')
+ .select(columns)
+ .eq('user_id', userId)
+ if (!includeDismissed) query = query.is('dismissed_at', null)
+ return query
+ .order('created_at', { ascending: false })
+ .returns()
+ },
+
+ async get(id: string) {
+ return supabase
+ .from('private_opportunities')
+ .select(columns)
+ .eq('id', id)
+ .maybeSingle()
+ },
+
+ async create(userId: string, input: PrivateOpportunityInput) {
+ return supabase
+ .from('private_opportunities')
+ .insert({ user_id: userId, ...input })
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async update(id: string, input: PrivateOpportunityUpdate) {
+ return supabase
+ .from('private_opportunities')
+ .update(input)
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async setDismissed(id: string, dismissed: boolean) {
+ return supabase
+ .from('private_opportunities')
+ .update({ dismissed_at: dismissed ? new Date().toISOString() : null })
+ .eq('id', id)
+ .select(columns)
+ .maybeSingle()
+ },
+
+ async remove(id: string) {
+ return supabase
+ .from('private_opportunities')
+ .delete()
+ .eq('id', id)
+ .select('id')
+ },
+}
diff --git a/app/src/pages/AppLayout.tsx b/app/src/pages/AppLayout.tsx
new file mode 100644
index 0000000..2f78c5b
--- /dev/null
+++ b/app/src/pages/AppLayout.tsx
@@ -0,0 +1,36 @@
+import { NavLink, Outlet } from 'react-router'
+import { useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+
+export function AppLayout() {
+ const { signOut } = useAuth()
+ const [error, setError] = useState(null)
+
+ async function handleSignOut() {
+ setError(null)
+ const result = await signOut()
+ if (result.error) setError(result.error)
+ }
+
+ return (
+
+
+ CareerOS
+ void handleSignOut()}
+ >
+ Sign out
+
+
+ {error && {error}
}
+
+ Profile
+ Opportunities
+ Applications
+
+
+
+ )
+}
diff --git a/app/src/pages/ApplicationDetailPage.test.tsx b/app/src/pages/ApplicationDetailPage.test.tsx
new file mode 100644
index 0000000..5b8681c
--- /dev/null
+++ b/app/src/pages/ApplicationDetailPage.test.tsx
@@ -0,0 +1,226 @@
+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 { ApplicationDetailPage } from './ApplicationDetailPage'
+
+const get = vi.fn()
+const updateStatus = vi.fn()
+const updateFields = vi.fn()
+const remove = vi.fn()
+const getVersion = vi.fn()
+const prepGet = vi.fn()
+const prepUpsert = vi.fn()
+const navigate = vi.fn()
+
+vi.mock('react-router', async () => {
+ const actual =
+ await vi.importActual('react-router')
+ return { ...actual, useNavigate: () => navigate }
+})
+vi.mock('../lib/applicationRepository', () => ({
+ applicationRepository: {
+ get: (...args: unknown[]) => get(...args),
+ updateStatus: (...args: unknown[]) => updateStatus(...args),
+ updateFields: (...args: unknown[]) => updateFields(...args),
+ remove: (...args: unknown[]) => remove(...args),
+ },
+}))
+vi.mock('../lib/opportunityRepository', () => ({
+ opportunityRepository: {
+ getVersion: (...args: unknown[]) => getVersion(...args),
+ },
+}))
+vi.mock('../lib/interviewPrepRepository', () => ({
+ interviewPrepRepository: {
+ get: (...args: unknown[]) => prepGet(...args),
+ upsert: (...args: unknown[]) => prepUpsert(...args),
+ },
+}))
+
+const sharedApp = {
+ id: 'app-1',
+ user_id: 'user-1',
+ opportunity_version_id: 'ver-1',
+ private_opportunity_id: null,
+ private_opportunity_snapshot: null,
+ status: 'preparing' as const,
+ applied_at: null,
+ status_updated_at: '2026-08-01T00:00:00Z',
+ next_action: null,
+ next_action_due_at: null,
+ notes: null,
+ contact_note: null,
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+}
+
+const version = {
+ id: 'ver-1',
+ opportunity_id: 'opp-1',
+ title: 'Senior Data Scientist',
+ organization: 'KONUX',
+ description: 'About us: KONUX builds sensors.',
+ location_text: 'Munich',
+ remote_mode: 'onsite',
+ opportunity_kind: 'other',
+ employment_type: 'full_time',
+ application_url: 'https://job-boards.greenhouse.io/konux/jobs/1',
+ posted_at: null,
+ application_deadline: null,
+ captured_at: '2026-08-01T00:00:00Z',
+}
+
+function renderPage() {
+ return render(
+
+
+ }
+ />
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('ApplicationDetailPage', () => {
+ it('shows a missing state for a deleted application', async () => {
+ get.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument()
+ })
+
+ it('renders the pinned listing snapshot beside interview prep', async () => {
+ get.mockResolvedValue({ data: sharedApp, error: null })
+ getVersion.mockResolvedValue({ data: version, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(
+ await screen.findByRole('heading', { name: 'Senior Data Scientist' }),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(/About us: KONUX builds sensors\./),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(/exact content saved at application time/i),
+ ).toBeInTheDocument()
+ const applyLink = screen.getByRole('link', {
+ name: /authoritative external application page/i,
+ })
+ expect(applyLink).toHaveAttribute('target', '_blank')
+ expect(applyLink).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('changes status and auto-sets an applied date', async () => {
+ get.mockResolvedValue({ data: sharedApp, error: null })
+ getVersion.mockResolvedValue({ data: version, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ updateStatus.mockResolvedValue({
+ data: {
+ ...sharedApp,
+ status: 'applied',
+ applied_at: '2026-08-04T00:00:00Z',
+ },
+ error: null,
+ })
+ renderPage()
+ await screen.findByRole('heading', { name: 'Senior Data Scientist' })
+ await userEvent.selectOptions(screen.getByLabelText('Status'), 'applied')
+ expect(updateStatus).toHaveBeenCalledWith('app-1', 'applied')
+ })
+
+ it('saves interview prep notes', async () => {
+ get.mockResolvedValue({ data: sharedApp, error: null })
+ getVersion.mockResolvedValue({ data: version, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ prepUpsert.mockResolvedValue({
+ data: {
+ id: 'prep-1',
+ application_id: 'app-1',
+ responsibilities_to_discuss: null,
+ required_technologies: null,
+ topics_to_revise: null,
+ likely_questions: 'Tell me about a model you deployed.',
+ questions_to_ask: null,
+ interview_date: null,
+ interview_format: null,
+ reflections: null,
+ },
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Senior Data Scientist' })
+ await user.type(
+ screen.getByLabelText('Likely interview questions'),
+ 'Tell me about a model you deployed.',
+ )
+ await user.click(
+ screen.getByRole('button', { name: /save interview prep/i }),
+ )
+ expect(prepUpsert).toHaveBeenCalledWith(
+ 'app-1',
+ expect.objectContaining({
+ likely_questions: 'Tell me about a model you deployed.',
+ }),
+ )
+ expect(
+ await screen.findByText(/interview prep notes saved/i),
+ ).toBeInTheDocument()
+ })
+
+ it('asks for confirmation before deleting the application', async () => {
+ get.mockResolvedValue({ data: sharedApp, error: null })
+ getVersion.mockResolvedValue({ data: version, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Senior Data Scientist' })
+ await user.click(screen.getByRole('button', { name: 'Delete application' }))
+ expect(confirmSpy).toHaveBeenCalled()
+ expect(remove).not.toHaveBeenCalled()
+ confirmSpy.mockRestore()
+ })
+
+ it('deletes the application and navigates back once confirmed', async () => {
+ get.mockResolvedValue({ data: sharedApp, error: null })
+ getVersion.mockResolvedValue({ data: version, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ remove.mockResolvedValue({ data: [{ id: 'app-1' }], error: null })
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Senior Data Scientist' })
+ await user.click(screen.getByRole('button', { name: 'Delete application' }))
+ expect(remove).toHaveBeenCalledWith('app-1')
+ expect(navigate).toHaveBeenCalledWith('/applications', { replace: true })
+ confirmSpy.mockRestore()
+ })
+
+ it('renders a manual application snapshot without fetching a shared version', async () => {
+ const manualApp = {
+ ...sharedApp,
+ opportunity_version_id: null,
+ private_opportunity_id: 'priv-1',
+ private_opportunity_snapshot: {
+ title: 'Research Assistant',
+ organization_name: 'Fraunhofer IWES',
+ description_text: 'Support the sensor team.',
+ application_url: null,
+ source_url: 'https://jobs.fraunhofer.de/example',
+ },
+ }
+ get.mockResolvedValue({ data: manualApp, error: null })
+ prepGet.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(
+ await screen.findByRole('heading', { name: 'Research Assistant' }),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Support the sensor team.')).toBeInTheDocument()
+ expect(getVersion).not.toHaveBeenCalled()
+ })
+})
diff --git a/app/src/pages/ApplicationDetailPage.tsx b/app/src/pages/ApplicationDetailPage.tsx
new file mode 100644
index 0000000..1fb1ff6
--- /dev/null
+++ b/app/src/pages/ApplicationDetailPage.tsx
@@ -0,0 +1,475 @@
+import { Link, useNavigate, useParams } from 'react-router'
+import { useCallback, useEffect, useState, type FormEvent } from 'react'
+import { applicationRepository } from '../lib/applicationRepository'
+import { opportunityRepository } from '../lib/opportunityRepository'
+import { interviewPrepRepository } from '../lib/interviewPrepRepository'
+import {
+ applicationStatusLabels,
+ applicationStatusOptions,
+ type Application,
+ type ApplicationStatus,
+ type InterviewPrepNotes,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+type PinnedListing = {
+ title: string
+ organization: string
+ description: string
+ applicationUrl: string | null
+ sourceUrl?: string
+}
+
+function formatDate(value: string | null) {
+ if (!value) return null
+ return new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(value))
+}
+
+function toDateInput(value: string | null) {
+ return value ? value.slice(0, 10) : ''
+}
+
+export function ApplicationDetailPage() {
+ const { applicationId } = useParams()
+ const navigate = useNavigate()
+
+ const [status, setStatus] = useState<
+ 'loading' | 'ready' | 'missing' | 'error'
+ >('loading')
+ const [application, setApplication] = useState(null)
+ const [pinned, setPinned] = useState(null)
+ const [opportunityId, setOpportunityId] = useState(null)
+ const [message, setMessage] = useState(null)
+ const [pending, setPending] = useState(false)
+
+ const [nextAction, setNextAction] = useState('')
+ const [nextActionDueAt, setNextActionDueAt] = useState('')
+ const [notes, setNotes] = useState('')
+ const [contactNote, setContactNote] = useState('')
+
+ const [prep, setPrep] = useState(null)
+ const [prepValues, setPrepValues] = useState({
+ responsibilities_to_discuss: '',
+ required_technologies: '',
+ topics_to_revise: '',
+ likely_questions: '',
+ questions_to_ask: '',
+ interview_date: '',
+ interview_format: '',
+ reflections: '',
+ })
+ const [prepSaving, setPrepSaving] = useState(false)
+
+ const load = useCallback(async () => {
+ if (!applicationId) return
+ setStatus('loading')
+ const result = await applicationRepository.get(applicationId)
+ if (result.error) {
+ setStatus('error')
+ return
+ }
+ if (!result.data) {
+ setStatus('missing')
+ return
+ }
+ const app = result.data
+ setApplication(app)
+ setNextAction(app.next_action ?? '')
+ setNextActionDueAt(toDateInput(app.next_action_due_at))
+ setNotes(app.notes ?? '')
+ setContactNote(app.contact_note ?? '')
+
+ if (app.opportunity_version_id) {
+ const versionResult = await opportunityRepository.getVersion(
+ app.opportunity_version_id,
+ )
+ if (versionResult.data) {
+ setOpportunityId(versionResult.data.opportunity_id as string)
+ setPinned({
+ title: versionResult.data.title as string,
+ organization: versionResult.data.organization as string,
+ description: versionResult.data.description as string,
+ applicationUrl: versionResult.data.application_url as string | null,
+ })
+ }
+ } else if (app.private_opportunity_snapshot) {
+ const snapshot = app.private_opportunity_snapshot
+ setPinned({
+ title: (snapshot.title as string) ?? 'Manual opportunity',
+ organization: (snapshot.organization_name as string) ?? '',
+ description: (snapshot.description_text as string) ?? '',
+ applicationUrl: (snapshot.application_url as string | null) ?? null,
+ sourceUrl: (snapshot.source_url as string | undefined) ?? undefined,
+ })
+ }
+
+ const prepResult = await interviewPrepRepository.get(applicationId)
+ if (!prepResult.error && prepResult.data) {
+ setPrep(prepResult.data)
+ setPrepValues({
+ responsibilities_to_discuss:
+ prepResult.data.responsibilities_to_discuss ?? '',
+ required_technologies: prepResult.data.required_technologies ?? '',
+ topics_to_revise: prepResult.data.topics_to_revise ?? '',
+ likely_questions: prepResult.data.likely_questions ?? '',
+ questions_to_ask: prepResult.data.questions_to_ask ?? '',
+ interview_date: toDateInput(prepResult.data.interview_date),
+ interview_format: prepResult.data.interview_format ?? '',
+ reflections: prepResult.data.reflections ?? '',
+ })
+ }
+ setStatus('ready')
+ }, [applicationId])
+
+ 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 changeStatus(next: ApplicationStatus) {
+ if (!application || pending) return
+ setPending(true)
+ const result = await applicationRepository.updateStatus(
+ application.id,
+ next,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await load()
+ setPending(false)
+ }
+
+ async function saveFields(event: FormEvent) {
+ event.preventDefault()
+ if (!application || pending) return
+ setPending(true)
+ const result = await applicationRepository.updateFields(application.id, {
+ next_action: nextAction.trim() || null,
+ next_action_due_at: nextActionDueAt || null,
+ notes: notes.trim() || null,
+ contact_note: contactNote.trim() || null,
+ })
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else {
+ setApplication(result.data)
+ setMessage('Saved.')
+ }
+ setPending(false)
+ }
+
+ async function savePrep(event: FormEvent) {
+ event.preventDefault()
+ if (!applicationId || prepSaving) return
+ setPrepSaving(true)
+ const result = await interviewPrepRepository.upsert(applicationId, {
+ responsibilities_to_discuss:
+ prepValues.responsibilities_to_discuss.trim() || null,
+ required_technologies: prepValues.required_technologies.trim() || null,
+ topics_to_revise: prepValues.topics_to_revise.trim() || null,
+ likely_questions: prepValues.likely_questions.trim() || null,
+ questions_to_ask: prepValues.questions_to_ask.trim() || null,
+ interview_date: prepValues.interview_date || null,
+ interview_format: prepValues.interview_format.trim() || null,
+ reflections: prepValues.reflections.trim() || null,
+ })
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else {
+ setPrep(result.data)
+ setMessage('Interview prep notes saved.')
+ }
+ setPrepSaving(false)
+ }
+
+ async function remove() {
+ if (!application || pending) return
+ if (
+ !window.confirm(
+ 'Delete this application? This also deletes its interview-preparation notes.',
+ )
+ )
+ return
+ setPending(true)
+ const result = await applicationRepository.remove(application.id)
+ if (result.error) {
+ setMessage(errorMessage(safeError(result.error)))
+ setPending(false)
+ return
+ }
+ navigate('/applications', { replace: true })
+ }
+
+ if (status === 'loading')
+ return (
+
+ Application
+ Loading…
+
+ )
+ if (status === 'missing')
+ return (
+
+ Application
+ This application no longer exists.
+ Return to applications
+
+ )
+ if (status === 'error' || !application)
+ return (
+
+ Application
+ This application could not be loaded.
+ void load()}>
+ Retry
+
+
+ )
+
+ return (
+
+ {pinned?.title ?? 'Application'}
+ {pinned && {pinned.organization}
}
+ {message && {message}
}
+
+
+ Status
+
+ void changeStatus(event.target.value as ApplicationStatus)
+ }
+ >
+ {applicationStatusOptions.map((value) => (
+
+ {applicationStatusLabels[value]}
+
+ ))}
+
+
+
+ Status updated {formatDate(application.status_updated_at)}
+ {application.applied_at && (
+ <> · Applied {formatDate(application.applied_at)}>
+ )}
+
+
+
+
+ void remove()}
+ >
+ Delete application
+
+
+ {opportunityId && (
+
+
+ Open the current opportunity page
+ {' '}
+ (shows the latest listing content, not necessarily what you applied to
+ — see the pinned snapshot below)
+
+ )}
+ {pinned?.applicationUrl && (
+
+
+ Open the authoritative external application page
+
+
+ )}
+ {pinned?.sourceUrl && (
+
+
+ Open the original source listing
+
+
+ )}
+
+ {pinned && (
+
+ Pinned listing snapshot
+
+ The exact content saved at application time — later changes to the
+ source listing never alter this.
+
+ {pinned.description}
+
+ )}
+
+
+ Interview preparation
+
+
+
+ Responsibilities to discuss
+
+
+ setPrepValues((current) => ({
+ ...current,
+ responsibilities_to_discuss: event.target.value,
+ }))
+ }
+ />
+
+
+ Required technologies
+
+ setPrepValues((current) => ({
+ ...current,
+ required_technologies: event.target.value,
+ }))
+ }
+ />
+
+
+ Topics to revise
+
+ setPrepValues((current) => ({
+ ...current,
+ topics_to_revise: event.target.value,
+ }))
+ }
+ />
+
+
+ Likely interview questions
+
+ setPrepValues((current) => ({
+ ...current,
+ likely_questions: event.target.value,
+ }))
+ }
+ />
+
+
+
+ Questions to ask the employer
+
+
+ setPrepValues((current) => ({
+ ...current,
+ questions_to_ask: event.target.value,
+ }))
+ }
+ />
+
+
+ Interview date
+
+ setPrepValues((current) => ({
+ ...current,
+ interview_date: event.target.value,
+ }))
+ }
+ />
+
+
+ Interview format
+
+ setPrepValues((current) => ({
+ ...current,
+ interview_format: event.target.value,
+ }))
+ }
+ placeholder="e.g. on-site, phone, video"
+ />
+
+
+ Post-interview reflections
+
+ setPrepValues((current) => ({
+ ...current,
+ reflections: event.target.value,
+ }))
+ }
+ />
+
+
+ {prepSaving
+ ? 'Saving…'
+ : prep
+ ? 'Update interview prep'
+ : 'Save interview prep'}
+
+
+
+
+
+ Back to applications
+
+
+ )
+}
diff --git a/app/src/pages/ApplicationsListPage.test.tsx b/app/src/pages/ApplicationsListPage.test.tsx
new file mode 100644
index 0000000..06ca8ff
--- /dev/null
+++ b/app/src/pages/ApplicationsListPage.test.tsx
@@ -0,0 +1,133 @@
+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 { ApplicationsListPage } from './ApplicationsListPage'
+
+const list = vi.fn()
+const getVersions = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('../lib/applicationRepository', () => ({
+ applicationRepository: { list: (...args: unknown[]) => list(...args) },
+}))
+vi.mock('../lib/opportunityRepository', () => ({
+ opportunityRepository: {
+ getVersions: (...args: unknown[]) => getVersions(...args),
+ },
+}))
+
+const sharedApp = {
+ id: 'app-1',
+ user_id: 'user-1',
+ opportunity_version_id: 'ver-1',
+ private_opportunity_id: null,
+ private_opportunity_snapshot: null,
+ status: 'applied' as const,
+ applied_at: '2026-08-01T00:00:00Z',
+ status_updated_at: '2026-08-01T00:00:00Z',
+ next_action: 'Follow up with recruiter',
+ next_action_due_at: '2026-08-10T00:00:00Z',
+ notes: null,
+ contact_note: null,
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+}
+
+const manualApp = {
+ ...sharedApp,
+ id: 'app-2',
+ opportunity_version_id: null,
+ private_opportunity_id: 'priv-1',
+ private_opportunity_snapshot: {
+ title: 'Research Assistant',
+ organization_name: 'Fraunhofer IWES',
+ application_url: null,
+ },
+ status: 'preparing' as const,
+ next_action: null,
+ next_action_due_at: null,
+}
+
+function renderPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('ApplicationsListPage', () => {
+ it('shows loading before applications resolve', () => {
+ list.mockReturnValue(new Promise(() => {}))
+ renderPage()
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ })
+
+ it('shows an empty state with no applications', async () => {
+ list.mockResolvedValue({ data: [], error: null })
+ getVersions.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(await screen.findByText(/No applications yet/i)).toBeInTheDocument()
+ })
+
+ it('shows a retry option on a loading failure', async () => {
+ list.mockResolvedValue({ data: null, error: { code: '', message: 'x' } })
+ renderPage()
+ expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+
+ it('renders a shared application using the resolved version title/organization', async () => {
+ list.mockResolvedValue({ data: [sharedApp], error: null })
+ getVersions.mockResolvedValue({
+ data: [
+ {
+ id: 'ver-1',
+ title: 'Working Student',
+ organization: 'Helsing',
+ application_url: null,
+ },
+ ],
+ error: null,
+ })
+ renderPage()
+ expect(await screen.findByText('Working Student')).toBeInTheDocument()
+ expect(screen.getByText(/at Helsing/)).toBeInTheDocument()
+ expect(screen.getByText(/Follow up with recruiter/)).toBeInTheDocument()
+ })
+
+ it('renders a manual application using its snapshot, without a version lookup', async () => {
+ list.mockResolvedValue({ data: [manualApp], error: null })
+ getVersions.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(await screen.findByText('Research Assistant')).toBeInTheDocument()
+ expect(screen.getByText(/at Fraunhofer IWES/)).toBeInTheDocument()
+ })
+
+ it('filters applications by status', async () => {
+ list.mockResolvedValue({ data: [sharedApp, manualApp], error: null })
+ getVersions.mockResolvedValue({
+ data: [
+ {
+ id: 'ver-1',
+ title: 'Working Student',
+ organization: 'Helsing',
+ application_url: null,
+ },
+ ],
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Working Student')
+ expect(screen.getByText('Research Assistant')).toBeInTheDocument()
+ await user.selectOptions(screen.getByLabelText('Status'), 'applied')
+ expect(screen.getByText('Working Student')).toBeInTheDocument()
+ expect(screen.queryByText('Research Assistant')).not.toBeInTheDocument()
+ })
+})
diff --git a/app/src/pages/ApplicationsListPage.tsx b/app/src/pages/ApplicationsListPage.tsx
new file mode 100644
index 0000000..d11dc9d
--- /dev/null
+++ b/app/src/pages/ApplicationsListPage.tsx
@@ -0,0 +1,202 @@
+import { Link } from 'react-router'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { applicationRepository } from '../lib/applicationRepository'
+import { opportunityRepository } from '../lib/opportunityRepository'
+import {
+ applicationStatusLabels,
+ applicationStatusOptions,
+ type Application,
+} from '../lib/opportunityTypes'
+
+type VersionInfo = {
+ title: string
+ organization: string
+ application_url: string | null
+}
+
+function formatDate(value: string | null) {
+ if (!value) return null
+ return new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(value))
+}
+
+type Sort = 'next_action' | 'recent_update'
+
+export function ApplicationsListPage() {
+ const { session } = useAuth()
+ const userId = session?.user.id
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
+ const [applications, setApplications] = useState([])
+ const [versions, setVersions] = useState>(new Map())
+ const [statusFilter, setStatusFilter] = useState('')
+ const [sort, setSort] = useState('recent_update')
+
+ const load = useCallback(async () => {
+ if (!userId) return
+ setStatus('loading')
+ const result = await applicationRepository.list(userId)
+ if (result.error) {
+ setStatus('error')
+ return
+ }
+ setApplications(result.data)
+ const versionIds = result.data
+ .map((app) => app.opportunity_version_id)
+ .filter((id): id is string => Boolean(id))
+ const versionsResult = await opportunityRepository.getVersions(versionIds)
+ if (!versionsResult.error) {
+ setVersions(
+ new Map(
+ (versionsResult.data ?? []).map((row) => [
+ row.id as string,
+ {
+ title: row.title as string,
+ organization: row.organization as string,
+ application_url: row.application_url as string | null,
+ },
+ ]),
+ ),
+ )
+ }
+ 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])
+
+ function titleFor(app: Application): VersionInfo {
+ if (app.opportunity_version_id) {
+ return (
+ versions.get(app.opportunity_version_id) ?? {
+ title: 'Opportunity',
+ organization: '',
+ application_url: null,
+ }
+ )
+ }
+ const snapshot = app.private_opportunity_snapshot ?? {}
+ return {
+ title: (snapshot.title as string) ?? 'Manual opportunity',
+ organization: (snapshot.organization_name as string) ?? '',
+ application_url: (snapshot.application_url as string | null) ?? null,
+ }
+ }
+
+ const visible = useMemo(() => {
+ let list = applications
+ if (statusFilter) list = list.filter((app) => app.status === statusFilter)
+ list = [...list]
+ if (sort === 'next_action') {
+ list.sort((a, b) => {
+ if (!a.next_action_due_at) return 1
+ if (!b.next_action_due_at) return -1
+ return a.next_action_due_at.localeCompare(b.next_action_due_at)
+ })
+ } else {
+ list.sort((a, b) =>
+ b.status_updated_at.localeCompare(a.status_updated_at),
+ )
+ }
+ return list
+ }, [applications, statusFilter, sort])
+
+ if (status === 'loading')
+ return (
+
+ Applications
+ Loading…
+
+ )
+ if (status === 'error')
+ return (
+
+ Applications
+ Applications could not be loaded.
+ void load()}>
+ Retry
+
+
+ )
+
+ return (
+
+ Applications
+ Status
+ setStatusFilter(event.target.value)}
+ >
+ Any status
+ {applicationStatusOptions.map((value) => (
+
+ {applicationStatusLabels[value]}
+
+ ))}
+
+ Sort
+ setSort(event.target.value as Sort)}
+ >
+ Recently updated
+ Next action due
+
+
+ {visible.length === 0 ? (
+ No applications yet. Start one from an opportunity's detail page.
+ ) : (
+
+ {visible.map((app) => {
+ const info = titleFor(app)
+ return (
+
+
+ {info.title}
+ {' '}
+ at {info.organization}
+
+ Status: {applicationStatusLabels[app.status]}
+ {app.applied_at && (
+ <>
+ {' '}
+ · Applied {formatDate(app.applied_at)}
+ >
+ )}
+ {app.next_action && (
+ <>
+
+ Next: {app.next_action}
+ {app.next_action_due_at && (
+ (due {formatDate(app.next_action_due_at)})
+ )}
+ >
+ )}
+ {info.application_url && (
+ <>
+ {' '}
+ ·{' '}
+
+ Open application page
+
+ >
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/app/src/pages/ManualOpportunityFormPage.test.tsx b/app/src/pages/ManualOpportunityFormPage.test.tsx
new file mode 100644
index 0000000..47e42d3
--- /dev/null
+++ b/app/src/pages/ManualOpportunityFormPage.test.tsx
@@ -0,0 +1,108 @@
+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 { ManualOpportunityFormPage } from './ManualOpportunityFormPage'
+
+const create = vi.fn()
+const navigate = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('react-router', async () => {
+ const actual =
+ await vi.importActual('react-router')
+ return { ...actual, useNavigate: () => navigate }
+})
+vi.mock('../lib/privateOpportunityRepository', () => ({
+ privateOpportunityRepository: {
+ create: (...args: unknown[]) => create(...args),
+ },
+}))
+
+function renderPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('ManualOpportunityFormPage', () => {
+ it('rejects submission without a valid https source URL', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ await user.type(
+ screen.getByLabelText('Original URL (https://)'),
+ 'not-a-url',
+ )
+ await user.click(screen.getByRole('button', { name: /add opportunity/i }))
+ expect(
+ await screen.findByText(/enter the https:\/\/ url/i),
+ ).toBeInTheDocument()
+ expect(create).not.toHaveBeenCalled()
+ })
+
+ it('requires title, organization, and location', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ await user.type(
+ screen.getByLabelText('Original URL (https://)'),
+ 'https://example.test/job',
+ )
+ await user.click(screen.getByRole('button', { name: /add opportunity/i }))
+ expect(await screen.findByText(/title is required/i)).toBeInTheDocument()
+ expect(screen.getByText(/organization is required/i)).toBeInTheDocument()
+ expect(screen.getByText(/location is required/i)).toBeInTheDocument()
+ })
+
+ it('submits a valid manual opportunity and navigates to its detail page', async () => {
+ create.mockResolvedValue({ data: { id: 'priv-1' }, error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await user.type(
+ screen.getByLabelText('Original URL (https://)'),
+ 'https://jobs.fraunhofer.de/example',
+ )
+ await user.type(screen.getByLabelText('Title'), 'Research Assistant')
+ await user.type(screen.getByLabelText('Organization'), 'Fraunhofer IWES')
+ await user.type(screen.getByLabelText('Location'), 'Bremen, Germany')
+ await user.click(screen.getByRole('button', { name: /add opportunity/i }))
+ expect(create).toHaveBeenCalledWith(
+ 'user-1',
+ expect.objectContaining({
+ source_url: 'https://jobs.fraunhofer.de/example',
+ title: 'Research Assistant',
+ organization_name: 'Fraunhofer IWES',
+ location_text: 'Bremen, Germany',
+ }),
+ )
+ expect(navigate).toHaveBeenCalledWith('/opportunities/manual/priv-1', {
+ replace: true,
+ })
+ })
+
+ it('shows a validation error message when the save fails', async () => {
+ create.mockResolvedValue({
+ data: null,
+ error: { code: '23514', message: 'x' },
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await user.type(
+ screen.getByLabelText('Original URL (https://)'),
+ 'https://jobs.fraunhofer.de/example',
+ )
+ await user.type(screen.getByLabelText('Title'), 'Research Assistant')
+ await user.type(screen.getByLabelText('Organization'), 'Fraunhofer IWES')
+ await user.type(screen.getByLabelText('Location'), 'Bremen, Germany')
+ await user.click(screen.getByRole('button', { name: /add opportunity/i }))
+ expect(
+ await screen.findByText(/check the highlighted values/i),
+ ).toBeInTheDocument()
+ expect(navigate).not.toHaveBeenCalled()
+ })
+})
diff --git a/app/src/pages/ManualOpportunityFormPage.tsx b/app/src/pages/ManualOpportunityFormPage.tsx
new file mode 100644
index 0000000..70ee689
--- /dev/null
+++ b/app/src/pages/ManualOpportunityFormPage.tsx
@@ -0,0 +1,232 @@
+import { Link, useNavigate } from 'react-router'
+import { useState, type FormEvent } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { privateOpportunityRepository } from '../lib/privateOpportunityRepository'
+import {
+ employmentTypeLabels,
+ employmentTypeOptions,
+ opportunityKindLabels,
+ opportunityKindOptions,
+ remoteModeLabels,
+ remoteModeOptions,
+ type EmploymentType,
+ type OpportunityKind,
+ type RemoteMode,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+const empty = {
+ source_url: '',
+ title: '',
+ organization_name: '',
+ location_text: '',
+ opportunity_kind: 'other' as OpportunityKind,
+ employment_type: 'other' as EmploymentType,
+ remote_mode: 'unknown' as RemoteMode,
+ description_text: '',
+ posted_at: '',
+ application_deadline: '',
+ application_url: '',
+}
+
+function validate(values: typeof empty) {
+ const errors: Record = {}
+ if (!values.source_url.trim().startsWith('https://'))
+ errors.source_url = 'Enter the https:// URL where you found this listing.'
+ if (!values.title.trim()) errors.title = 'Title is required.'
+ if (!values.organization_name.trim())
+ errors.organization_name = 'Organization is required.'
+ if (!values.location_text.trim())
+ errors.location_text = 'Location is required.'
+ if (values.application_url && !values.application_url.startsWith('https://'))
+ errors.application_url = 'Application URL must start with https://.'
+ return errors
+}
+
+export function ManualOpportunityFormPage() {
+ const { session } = useAuth()
+ const navigate = useNavigate()
+ const [values, setValues] = useState(empty)
+ const [errors, setErrors] = useState>({})
+ const [message, setMessage] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ function field(name: K, value: string) {
+ setValues((current) => ({ ...current, [name]: value }))
+ }
+
+ async function save(event: FormEvent) {
+ event.preventDefault()
+ const nextErrors = validate(values)
+ setErrors(nextErrors)
+ if (Object.keys(nextErrors).length || saving || !session) return
+ setSaving(true)
+ const result = await privateOpportunityRepository.create(session.user.id, {
+ source_url: values.source_url.trim(),
+ title: values.title.trim(),
+ organization_name: values.organization_name.trim(),
+ location_text: values.location_text.trim(),
+ opportunity_kind: values.opportunity_kind,
+ employment_type: values.employment_type,
+ remote_mode: values.remote_mode,
+ description_text: values.description_text.trim() || null,
+ posted_at: values.posted_at || null,
+ application_deadline: values.application_deadline || null,
+ application_url: values.application_url.trim() || null,
+ })
+ if (result.error || !result.data) {
+ setMessage(errorMessage(safeError(result.error)))
+ setSaving(false)
+ return
+ }
+ navigate(`/opportunities/manual/${result.data.id}`, { replace: true })
+ }
+
+ return (
+
+ Add an opportunity manually
+
+ Use this for a posting from a source CareerOS doesn't automatically
+ import. You confirm the details yourself — nothing is fetched
+ automatically.
+
+
+
+
Original URL (https://)
+
field('source_url', event.target.value)}
+ aria-invalid={Boolean(errors.source_url)}
+ />
+ {errors.source_url &&
{errors.source_url}
}
+
+
+
Title
+
field('title', event.target.value)}
+ aria-invalid={Boolean(errors.title)}
+ />
+ {errors.title &&
{errors.title}
}
+
+
+
Organization
+
field('organization_name', event.target.value)}
+ aria-invalid={Boolean(errors.organization_name)}
+ />
+ {errors.organization_name && (
+
{errors.organization_name}
+ )}
+
+
+
Location
+
field('location_text', event.target.value)}
+ aria-invalid={Boolean(errors.location_text)}
+ />
+ {errors.location_text &&
{errors.location_text}
}
+
+
+ Kind
+ field('opportunity_kind', event.target.value)}
+ >
+ {opportunityKindOptions.map((value) => (
+
+ {opportunityKindLabels[value]}
+
+ ))}
+
+
+
+ Employment type
+ field('employment_type', event.target.value)}
+ >
+ {employmentTypeOptions.map((value) => (
+
+ {employmentTypeLabels[value]}
+
+ ))}
+
+
+
+ Remote mode
+ field('remote_mode', event.target.value)}
+ >
+ {remoteModeOptions.map((value) => (
+
+ {remoteModeLabels[value]}
+
+ ))}
+
+
+
+ Description / requirements
+ field('description_text', event.target.value)}
+ />
+
+
+ Posted date (if known)
+ field('posted_at', event.target.value)}
+ />
+
+
+ Deadline (if known)
+
+ field('application_deadline', event.target.value)
+ }
+ />
+
+
+
+ Application URL (if different from the original URL)
+
+
field('application_url', event.target.value)}
+ aria-invalid={Boolean(errors.application_url)}
+ />
+ {errors.application_url && (
+
{errors.application_url}
+ )}
+
+
+ {saving ? 'Saving…' : 'Add opportunity'}
+
+
+ {message && {message}
}
+
+ Cancel
+
+
+ )
+}
diff --git a/app/src/pages/OpportunityDetailPage.test.tsx b/app/src/pages/OpportunityDetailPage.test.tsx
new file mode 100644
index 0000000..c130e5b
--- /dev/null
+++ b/app/src/pages/OpportunityDetailPage.test.tsx
@@ -0,0 +1,200 @@
+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 { OpportunityDetailPage } from './OpportunityDetailPage'
+
+const get = vi.fn()
+const getState = vi.fn()
+const getVersion = vi.fn()
+const setSaved = vi.fn()
+const setHidden = vi.fn()
+const getByOpportunity = vi.fn()
+const createForOpportunity = vi.fn()
+const navigate = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('react-router', async () => {
+ const actual =
+ await vi.importActual('react-router')
+ return { ...actual, useNavigate: () => navigate }
+})
+vi.mock('../lib/opportunityRepository', () => ({
+ opportunityRepository: {
+ get: (...args: unknown[]) => get(...args),
+ getState: (...args: unknown[]) => getState(...args),
+ getVersion: (...args: unknown[]) => getVersion(...args),
+ setSaved: (...args: unknown[]) => setSaved(...args),
+ setHidden: (...args: unknown[]) => setHidden(...args),
+ },
+}))
+vi.mock('../lib/applicationRepository', () => ({
+ applicationRepository: {
+ getByOpportunity: (...args: unknown[]) => getByOpportunity(...args),
+ createForOpportunity: (...args: unknown[]) => createForOpportunity(...args),
+ },
+}))
+
+const row = {
+ opportunity_id: 'opp-1',
+ opportunity_version_id: 'ver-1',
+ lifecycle_status: 'active' as const,
+ first_discovered_at: '2026-08-01T00:00:00Z',
+ last_checked_at: '2026-08-01T00:00:00Z',
+ source_id: 'source-1',
+ source_key: 'greenhouse:helsing',
+ source_display_name: 'Helsing (Greenhouse)',
+ source_listing_id: 'listing-1',
+ canonical_source_url: 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ title: 'Working Student, Flight Software',
+ organization: 'Helsing',
+ description: 'Support the autonomy team.',
+ location_text: 'Munich, Germany',
+ country: null,
+ region: null,
+ city: null,
+ opportunity_kind: 'working_student' as const,
+ employment_type: 'internship' as const,
+ remote_mode: 'onsite' as const,
+ posted_at: null,
+ application_deadline: null,
+ application_url: 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ version_captured_at: '2026-08-01T00:00:00Z',
+}
+
+function renderPage(id = 'opp-1') {
+ return render(
+
+
+ }
+ />
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('OpportunityDetailPage', () => {
+ it('shows loading before the opportunity resolves', () => {
+ get.mockReturnValue(new Promise(() => {}))
+ renderPage()
+ expect(screen.getByText('Loading…')).toBeInTheDocument()
+ })
+
+ it('shows a missing state for an opportunity that no longer exists', async () => {
+ get.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument()
+ })
+
+ it('shows a retry option on a loading failure', async () => {
+ get.mockResolvedValue({ data: null, error: { code: '', message: 'x' } })
+ renderPage()
+ expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+
+ it('renders full detail and a safe external link', async () => {
+ get.mockResolvedValue({ data: row, error: null })
+ getState.mockResolvedValue({ data: null, error: null })
+ getByOpportunity.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(
+ await screen.findByRole('heading', {
+ name: 'Working Student, Flight Software',
+ }),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Support the autonomy team.')).toBeInTheDocument()
+ const link = screen.getByRole('link', {
+ name: /open original source listing/i,
+ })
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
+ it('saves the opportunity, pinning the current version', async () => {
+ get.mockResolvedValue({ data: row, error: null })
+ getState.mockResolvedValue({ data: null, error: null })
+ getByOpportunity.mockResolvedValue({ data: null, error: null })
+ setSaved.mockResolvedValue({ data: {}, error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', {
+ name: 'Working Student, Flight Software',
+ })
+ await user.click(screen.getByRole('button', { name: 'Save' }))
+ expect(setSaved).toHaveBeenCalledWith('user-1', 'opp-1', 'ver-1', true)
+ })
+
+ it('shows the current-vs-saved-version banner when the saved version differs', async () => {
+ get.mockResolvedValue({ data: row, error: null })
+ getState.mockResolvedValue({
+ data: {
+ id: 'state-1',
+ user_id: 'user-1',
+ opportunity_id: 'opp-1',
+ saved_at: '2026-08-01T00:00:00Z',
+ saved_opportunity_version_id: 'ver-old',
+ hidden_at: null,
+ notes: null,
+ },
+ error: null,
+ })
+ getVersion.mockResolvedValue({
+ data: {
+ id: 'ver-old',
+ opportunity_id: 'opp-1',
+ title: 'Working Student, Flight Software (old)',
+ organization: 'Helsing',
+ description: 'Older description.',
+ captured_at: '2026-07-01T00:00:00Z',
+ },
+ error: null,
+ })
+ getByOpportunity.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(
+ await screen.findByText(/source listing has changed since you saved it/i),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Older description.')).toBeInTheDocument()
+ })
+
+ it('starts an application and navigates to it', async () => {
+ get.mockResolvedValue({ data: row, error: null })
+ getState.mockResolvedValue({ data: null, error: null })
+ getByOpportunity.mockResolvedValue({ data: null, error: null })
+ createForOpportunity.mockResolvedValue({
+ data: { id: 'app-1' },
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', {
+ name: 'Working Student, Flight Software',
+ })
+ await user.click(screen.getByRole('button', { name: 'Start application' }))
+ expect(createForOpportunity).toHaveBeenCalledWith('user-1', 'ver-1')
+ expect(navigate).toHaveBeenCalledWith('/applications/app-1')
+ })
+
+ it('shows a link to the existing application instead of "Start application" when one exists', async () => {
+ get.mockResolvedValue({ data: row, error: null })
+ getState.mockResolvedValue({ data: null, error: null })
+ getByOpportunity.mockResolvedValue({
+ data: { id: 'app-1' },
+ error: null,
+ })
+ renderPage()
+ expect(
+ await screen.findByRole('link', { name: /view application/i }),
+ ).toHaveAttribute('href', '/applications/app-1')
+ expect(
+ screen.queryByRole('button', { name: 'Start application' }),
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/app/src/pages/OpportunityDetailPage.tsx b/app/src/pages/OpportunityDetailPage.tsx
new file mode 100644
index 0000000..eb476fa
--- /dev/null
+++ b/app/src/pages/OpportunityDetailPage.tsx
@@ -0,0 +1,263 @@
+import { Link, useNavigate, useParams } from 'react-router'
+import { useCallback, useEffect, useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { opportunityRepository } from '../lib/opportunityRepository'
+import { applicationRepository } from '../lib/applicationRepository'
+import {
+ employmentTypeLabels,
+ opportunityKindLabels,
+ remoteModeLabels,
+ type OpportunitySearchRow,
+ type UserOpportunityStateRow,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+type SavedVersion = {
+ id: string
+ title: string
+ organization: string
+ description: string
+ captured_at: string
+}
+
+function formatDate(value: string | null) {
+ if (!value) return null
+ return new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(value))
+}
+
+export function OpportunityDetailPage() {
+ const { opportunityId } = useParams()
+ const { session } = useAuth()
+ const userId = session?.user.id
+ const navigate = useNavigate()
+
+ const [status, setStatus] = useState<
+ 'loading' | 'ready' | 'missing' | 'error'
+ >('loading')
+ const [row, setRow] = useState(null)
+ const [state, setState] = useState(null)
+ const [savedVersion, setSavedVersion] = useState(null)
+ const [applicationId, setApplicationId] = useState(null)
+ const [message, setMessage] = useState(null)
+ const [pending, setPending] = useState(false)
+
+ const load = useCallback(async () => {
+ if (!opportunityId || !userId) return
+ setStatus('loading')
+ const result = await opportunityRepository.get(opportunityId)
+ if (result.error) {
+ setStatus('error')
+ return
+ }
+ if (!result.data) {
+ setStatus('missing')
+ return
+ }
+ setRow(result.data)
+
+ const [stateResult, appResult] = await Promise.all([
+ opportunityRepository.getState(userId, opportunityId),
+ applicationRepository.getByOpportunity(userId, opportunityId),
+ ])
+ if (stateResult.error) {
+ setStatus('error')
+ return
+ }
+ setState(stateResult.data)
+ setApplicationId(appResult.data?.id ?? null)
+
+ if (
+ stateResult.data?.saved_opportunity_version_id &&
+ stateResult.data.saved_opportunity_version_id !==
+ result.data.opportunity_version_id
+ ) {
+ const versionResult = await opportunityRepository.getVersion(
+ stateResult.data.saved_opportunity_version_id,
+ )
+ setSavedVersion(versionResult.data as SavedVersion | null)
+ } else {
+ setSavedVersion(null)
+ }
+ setStatus('ready')
+ }, [opportunityId, 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 toggleSave() {
+ if (!userId || !row || pending) return
+ setPending(true)
+ const result = await opportunityRepository.setSaved(
+ userId,
+ row.opportunity_id,
+ row.opportunity_version_id,
+ !state?.saved_at,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await load()
+ setPending(false)
+ }
+
+ async function toggleHide() {
+ if (!userId || !row || pending) return
+ setPending(true)
+ const result = await opportunityRepository.setHidden(
+ userId,
+ row.opportunity_id,
+ !state?.hidden_at,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await load()
+ setPending(false)
+ }
+
+ async function startApplication() {
+ if (!userId || !row || pending) return
+ setPending(true)
+ const result = await applicationRepository.createForOpportunity(
+ userId,
+ row.opportunity_version_id,
+ )
+ if (result.error) {
+ if (result.error.code === '23505') {
+ const existing = await applicationRepository.getByOpportunity(
+ userId,
+ row.opportunity_id,
+ )
+ if (existing.data) {
+ navigate(`/applications/${existing.data.id}`)
+ return
+ }
+ }
+ setMessage(errorMessage(safeError(result.error)))
+ setPending(false)
+ return
+ }
+ navigate(`/applications/${result.data!.id}`)
+ }
+
+ if (status === 'loading')
+ return (
+
+ Opportunity
+ Loading…
+
+ )
+ if (status === 'missing')
+ return (
+
+ Opportunity
+ This opportunity no longer exists.
+ Return to opportunities
+
+ )
+ if (status === 'error' || !row)
+ return (
+
+ Opportunity
+ This opportunity could not be loaded.
+ void load()}>
+ Retry
+
+
+ )
+
+ return (
+
+ {row.title}
+
+ {row.organization} · {row.source_display_name}
+
+
+ {opportunityKindLabels[row.opportunity_kind]} ·{' '}
+ {employmentTypeLabels[row.employment_type]} ·{' '}
+ {remoteModeLabels[row.remote_mode]}
+
+ {row.location_text && {row.location_text}
}
+ {row.posted_at && Posted {formatDate(row.posted_at)}
}
+ {row.application_deadline && (
+ Deadline {formatDate(row.application_deadline)}
+ )}
+
+ First discovered {formatDate(row.first_discovered_at)} · Last checked{' '}
+ {formatDate(row.last_checked_at)} · Status: {row.lifecycle_status}
+
+
+ Description
+ {row.description}
+
+
+ {savedVersion && (
+
+
+ The source listing has changed since you saved it. Showing both
+ versions below.
+
+ Version you saved ({formatDate(savedVersion.captured_at)})
+
+ {savedVersion.title} at {savedVersion.organization}
+
+ {savedVersion.description}
+
+ )}
+
+
+
+ Open original source listing
+
+
+ {row.application_url && (
+
+
+ Open application page
+
+
+ )}
+
+ {message && {message}
}
+ void toggleSave()}
+ >
+ {state?.saved_at ? 'Unsave' : 'Save'}
+
+ void toggleHide()}
+ >
+ {state?.hidden_at ? 'Unhide' : 'Hide'}
+
+ {applicationId ? (
+ View application
+ ) : (
+ void startApplication()}
+ >
+ Start application
+
+ )}
+
+ Back to opportunities
+
+
+ )
+}
diff --git a/app/src/pages/OpportunityListPage.test.tsx b/app/src/pages/OpportunityListPage.test.tsx
new file mode 100644
index 0000000..df358a7
--- /dev/null
+++ b/app/src/pages/OpportunityListPage.test.tsx
@@ -0,0 +1,313 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { OpportunityListPage } from './OpportunityListPage'
+
+const search = vi.fn()
+const listState = vi.fn()
+const setSaved = vi.fn()
+const setHidden = vi.fn()
+const listSources = vi.fn()
+const applicationsList = vi.fn()
+const privateList = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('../lib/opportunityRepository', () => ({
+ PAGE_SIZE: 20,
+ opportunityRepository: {
+ search: (...args: unknown[]) => search(...args),
+ listState: (...args: unknown[]) => listState(...args),
+ setSaved: (...args: unknown[]) => setSaved(...args),
+ setHidden: (...args: unknown[]) => setHidden(...args),
+ listSources: (...args: unknown[]) => listSources(...args),
+ },
+}))
+vi.mock('../lib/privateOpportunityRepository', () => ({
+ privateOpportunityRepository: {
+ list: (...args: unknown[]) => privateList(...args),
+ },
+}))
+vi.mock('../lib/applicationRepository', () => ({
+ applicationRepository: {
+ list: (...args: unknown[]) => applicationsList(...args),
+ },
+}))
+
+const row = {
+ opportunity_id: 'opp-1',
+ opportunity_version_id: 'ver-1',
+ lifecycle_status: 'active' as const,
+ first_discovered_at: '2026-08-01T00:00:00Z',
+ last_checked_at: '2026-08-01T00:00:00Z',
+ source_id: 'source-1',
+ source_key: 'greenhouse:helsing',
+ source_display_name: 'Helsing (Greenhouse)',
+ source_listing_id: 'listing-1',
+ canonical_source_url: 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ title: 'Working Student, Flight Software',
+ organization: 'Helsing',
+ description: 'Description.',
+ location_text: 'Munich, Germany',
+ country: null,
+ region: null,
+ city: null,
+ opportunity_kind: 'working_student' as const,
+ employment_type: 'internship' as const,
+ remote_mode: 'onsite' as const,
+ posted_at: null,
+ application_deadline: null,
+ application_url: 'https://job-boards.greenhouse.io/helsing/jobs/1',
+ version_captured_at: '2026-08-01T00:00:00Z',
+}
+
+function renderPage() {
+ return render(
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ listSources.mockResolvedValue({ data: [], error: null })
+})
+afterEach(() => vi.resetAllMocks())
+
+describe('OpportunityListPage', () => {
+ it('shows loading before results resolve', () => {
+ search.mockReturnValue(new Promise(() => {}))
+ listState.mockReturnValue(new Promise(() => {}))
+ applicationsList.mockReturnValue(new Promise(() => {}))
+ privateList.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(screen.getAllByText('Loading…').length).toBeGreaterThan(0)
+ })
+
+ it('shows an empty state when no opportunities match', async () => {
+ search.mockResolvedValue({ data: [], count: 0, error: null })
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(
+ await screen.findByText(/No opportunities match your filters/i),
+ ).toBeInTheDocument()
+ })
+
+ it('shows a retry option on a loading failure', async () => {
+ search.mockResolvedValue({
+ data: null,
+ count: null,
+ error: { code: '', message: 'boom' },
+ })
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(await screen.findByText(/could not be loaded/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
+ })
+
+ it('renders a real-shaped listing with source, kind, and location', async () => {
+ search.mockResolvedValue({ data: [row], count: 1, error: null })
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ renderPage()
+ expect(
+ await screen.findByText('Working Student, Flight Software'),
+ ).toBeInTheDocument()
+ expect(screen.getByText(/Helsing \(Greenhouse\)/)).toBeInTheDocument()
+ expect(screen.getByText(/Munich, Germany/)).toBeInTheDocument()
+ })
+
+ it('toggles save on and reloads with the saved badge shown', async () => {
+ search.mockResolvedValue({ data: [row], count: 1, error: null })
+ listState
+ .mockResolvedValueOnce({ data: [], error: null })
+ .mockResolvedValueOnce({
+ data: [
+ {
+ id: 'state-1',
+ user_id: 'user-1',
+ opportunity_id: 'opp-1',
+ saved_at: '2026-08-01T00:00:00Z',
+ saved_opportunity_version_id: 'ver-1',
+ hidden_at: null,
+ notes: null,
+ },
+ ],
+ error: null,
+ })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ setSaved.mockResolvedValue({ data: {}, error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Working Student, Flight Software')
+ await user.click(screen.getByRole('button', { name: 'Save' }))
+ expect(setSaved).toHaveBeenCalledWith('user-1', 'opp-1', 'ver-1', true)
+ expect(
+ await screen.findByRole('button', { name: 'Unsave' }),
+ ).toBeInTheDocument()
+ })
+
+ it('excludes hidden opportunities server-side (before pagination), not by filtering an already-paginated page in React', async () => {
+ // Regression for the bug where hidden/saved/applied-only filtering was
+ // applied client-side *after* an already-paginated page came back,
+ // which could hide matching rows that existed on another unexamined
+ // page. The fix pushes exclude/include id sets into search() itself, so
+ // this asserts the exact filters.excludeOpportunityIds sent, and that
+ // the rendered rows/count come directly from whatever search() returns
+ // (no further client-side removal).
+ listState.mockResolvedValue({
+ data: [
+ {
+ id: 'state-1',
+ user_id: 'user-1',
+ opportunity_id: 'opp-1',
+ saved_at: null,
+ saved_opportunity_version_id: null,
+ hidden_at: '2026-08-01T00:00:00Z',
+ notes: null,
+ },
+ ],
+ error: null,
+ })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ search.mockImplementation(
+ (filters: { excludeOpportunityIds?: string[] }) => {
+ const excluded = filters.excludeOpportunityIds ?? []
+ if (excluded.includes('opp-1')) {
+ return Promise.resolve({ data: [], count: 0, error: null })
+ }
+ return Promise.resolve({ data: [row], count: 1, error: null })
+ },
+ )
+ const user = userEvent.setup()
+ renderPage()
+ expect(
+ await screen.findByText(/No opportunities match your filters/i),
+ ).toBeInTheDocument()
+ expect(search).toHaveBeenLastCalledWith(
+ expect.objectContaining({ excludeOpportunityIds: ['opp-1'] }),
+ expect.anything(),
+ 0,
+ )
+
+ await user.click(screen.getByLabelText('Show hidden'))
+ expect(
+ await screen.findByText('Working Student, Flight Software'),
+ ).toBeInTheDocument()
+ const lastCall = search.mock.calls.at(-1)
+ expect(
+ (lastCall?.[0] as { excludeOpportunityIds?: string[] })
+ .excludeOpportunityIds,
+ ).toBeUndefined()
+ })
+
+ it('short-circuits saved-only to zero rows via includeOpportunityIds: [] when nothing is saved, without a stale total', async () => {
+ search.mockImplementation(
+ (filters: { includeOpportunityIds?: string[] }) => {
+ if (filters.includeOpportunityIds) {
+ const matching =
+ filters.includeOpportunityIds.length === 0 ? [] : [row]
+ return Promise.resolve({
+ data: matching,
+ count: matching.length,
+ error: null,
+ })
+ }
+ return Promise.resolve({ data: [row], count: 1, error: null })
+ },
+ )
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Working Student, Flight Software')
+ await user.click(screen.getByLabelText('Saved only'))
+ expect(
+ await screen.findByText(/No opportunities match your filters/i),
+ ).toBeInTheDocument()
+ expect(search).toHaveBeenLastCalledWith(
+ expect.objectContaining({ includeOpportunityIds: [] }),
+ expect.anything(),
+ 0,
+ )
+ expect(screen.getByText(/^Page 1 of 1$/)).toBeInTheDocument()
+ })
+
+ it('resets to page 1 when a filter changes while on a later page', async () => {
+ search.mockResolvedValue({ data: [row], count: 45, error: null })
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({ data: [], error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByText('Working Student, Flight Software')
+ expect(screen.getByText(/^Page 1 of 3$/)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Next' }))
+ expect(await screen.findByText(/^Page 2 of 3$/)).toBeInTheDocument()
+ expect(search).toHaveBeenLastCalledWith(
+ expect.anything(),
+ expect.anything(),
+ 1,
+ )
+
+ await user.selectOptions(
+ screen.getByLabelText('Employment type'),
+ 'internship',
+ )
+ expect(await screen.findByText(/^Page 1 of 3$/)).toBeInTheDocument()
+ expect(search).toHaveBeenLastCalledWith(
+ expect.objectContaining({ employmentType: 'internship' }),
+ expect.anything(),
+ 0,
+ )
+ })
+
+ it('switches to the Manual tab and shows the user-added opportunity', async () => {
+ search.mockResolvedValue({ data: [], count: 0, error: null })
+ listState.mockResolvedValue({ data: [], error: null })
+ applicationsList.mockResolvedValue({ data: [], error: null })
+ privateList.mockResolvedValue({
+ data: [
+ {
+ id: 'priv-1',
+ user_id: 'user-1',
+ source_url: 'https://example.test/job',
+ title: 'Research Assistant',
+ organization_name: 'Fraunhofer IWES',
+ location_text: 'Bremen, Germany',
+ opportunity_kind: 'research_assistant',
+ employment_type: 'part_time',
+ remote_mode: 'unknown',
+ description_text: null,
+ posted_at: null,
+ application_deadline: null,
+ application_url: null,
+ dismissed_at: null,
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+ },
+ ],
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await user.click(screen.getByRole('tab', { name: 'Manual' }))
+ expect(await screen.findByText('Research Assistant')).toBeInTheDocument()
+ expect(
+ within(screen.getByText('Research Assistant').closest('li')!).getByText(
+ /Added by you/,
+ ),
+ ).toBeInTheDocument()
+ })
+})
diff --git a/app/src/pages/OpportunityListPage.tsx b/app/src/pages/OpportunityListPage.tsx
new file mode 100644
index 0000000..6473e21
--- /dev/null
+++ b/app/src/pages/OpportunityListPage.tsx
@@ -0,0 +1,593 @@
+import { Link } from 'react-router'
+import { useCallback, useEffect, useState } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import {
+ PAGE_SIZE,
+ opportunityRepository,
+ type OpportunityFilters,
+ type OpportunitySort,
+} from '../lib/opportunityRepository'
+import { privateOpportunityRepository } from '../lib/privateOpportunityRepository'
+import { applicationRepository } from '../lib/applicationRepository'
+import {
+ employmentTypeLabels,
+ employmentTypeOptions,
+ opportunityKindLabels,
+ opportunityKindOptions,
+ remoteModeLabels,
+ remoteModeOptions,
+ type OpportunitySearchRow,
+ type PrivateOpportunity,
+ type UserOpportunityStateRow,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+type Tab = 'discovered' | 'manual'
+type Status = 'loading' | 'ready' | 'error'
+type SourceOption = { source_key: string; display_name: string }
+
+const locationPresets = ['Bremen', 'Hamburg']
+
+function formatDate(value: string | null) {
+ if (!value) return null
+ return new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(value))
+}
+
+export function OpportunityListPage() {
+ const { session } = useAuth()
+ const userId = session?.user.id
+ const [tab, setTab] = useState('discovered')
+
+ // Discovered (shared) state -------------------------------------------
+ const [search, setSearch] = useState('')
+ const [kind, setKind] = useState('')
+ const [employmentType, setEmploymentType] = useState('')
+ const [remoteMode, setRemoteMode] = useState('')
+ const [sourceKey, setSourceKey] = useState('')
+ const [lifecycleStatus, setLifecycleStatus] = useState('')
+ const [locationText, setLocationText] = useState('')
+ const [sort, setSort] = useState('discovered_desc')
+ const [showHidden, setShowHidden] = useState(false)
+ const [savedOnly, setSavedOnly] = useState(false)
+ const [appliedOnly, setAppliedOnly] = useState(false)
+ const [page, setPage] = useState(0)
+ const [rows, setRows] = useState([])
+ const [total, setTotal] = useState(0)
+ const [stateMap, setStateMap] = useState<
+ Map
+ >(new Map())
+ const [appliedOpportunityIds, setAppliedOpportunityIds] = useState<
+ Set
+ >(new Set())
+ const [sources, setSources] = useState([])
+ const [discoveredStatus, setDiscoveredStatus] = useState('loading')
+ const [pendingSave, setPendingSave] = useState(null)
+
+ // Manual state ------------------------------------------------------
+ const [showDismissed, setShowDismissed] = useState(false)
+ const [manualRows, setManualRows] = useState([])
+ const [manualStatus, setManualStatus] = useState('loading')
+
+ const [message, setMessage] = useState(null)
+
+ const loadDiscovered = useCallback(async () => {
+ if (!userId) return
+ setDiscoveredStatus('loading')
+
+ // State/applications sets are small (per-user) -- fetch them first so
+ // saved/hidden/applied filters can be pushed into the shared-opportunity
+ // query itself, applied before pagination and the exact count, rather
+ // than filtered out of an already-paginated page client-side.
+ const [stateResult, applicationsResult] = await Promise.all([
+ opportunityRepository.listState(userId),
+ applicationRepository.list(userId),
+ ])
+ if (stateResult.error || applicationsResult.error) {
+ setDiscoveredStatus('error')
+ return
+ }
+ const nextStateMap = new Map(
+ stateResult.data.map((row) => [row.opportunity_id, row]),
+ )
+ setStateMap(nextStateMap)
+ const appliedIds = new Set(
+ applicationsResult.data
+ .map((app) => app.shared_opportunity_id)
+ .filter((id): id is string => Boolean(id)),
+ )
+ setAppliedOpportunityIds(appliedIds)
+
+ const hiddenIds = [...nextStateMap.values()]
+ .filter((row) => row.hidden_at)
+ .map((row) => row.opportunity_id)
+ const savedIds = [...nextStateMap.values()]
+ .filter((row) => row.saved_at)
+ .map((row) => row.opportunity_id)
+
+ const filters: OpportunityFilters = {
+ search,
+ opportunityKind: kind || undefined,
+ employmentType: employmentType || undefined,
+ remoteMode: remoteMode || undefined,
+ sourceKey: sourceKey || undefined,
+ lifecycleStatus: lifecycleStatus || undefined,
+ locationText: locationText || undefined,
+ }
+ if (!showHidden) filters.excludeOpportunityIds = hiddenIds
+ if (savedOnly && appliedOnly) {
+ filters.includeOpportunityIds = savedIds.filter((id) =>
+ appliedIds.has(id),
+ )
+ } else if (savedOnly) {
+ filters.includeOpportunityIds = savedIds
+ } else if (appliedOnly) {
+ filters.includeOpportunityIds = [...appliedIds]
+ }
+
+ const searchResult = await opportunityRepository.search(filters, sort, page)
+ if (searchResult.error) {
+ setDiscoveredStatus('error')
+ return
+ }
+ setRows(searchResult.data)
+ setTotal(searchResult.count ?? searchResult.data.length)
+ setDiscoveredStatus('ready')
+ }, [
+ userId,
+ search,
+ kind,
+ employmentType,
+ remoteMode,
+ sourceKey,
+ lifecycleStatus,
+ locationText,
+ sort,
+ showHidden,
+ savedOnly,
+ appliedOnly,
+ page,
+ ])
+
+ const loadManual = useCallback(async () => {
+ if (!userId) return
+ setManualStatus('loading')
+ const result = await privateOpportunityRepository.list(
+ userId,
+ showDismissed,
+ )
+ if (result.error) {
+ setManualStatus('error')
+ return
+ }
+ setManualRows(result.data)
+ setManualStatus('ready')
+ }, [userId, showDismissed])
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves
+ void loadDiscovered()
+ }, [loadDiscovered])
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves
+ void loadManual()
+ }, [loadManual])
+
+ useEffect(() => {
+ let cancelled = false
+ void opportunityRepository.listSources().then((result) => {
+ if (!cancelled && !result.error) setSources(result.data)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ function updateFilter(setter: (value: T) => void, value: T) {
+ setter(value)
+ setPage(0)
+ }
+
+ async function toggleSave(row: OpportunitySearchRow) {
+ if (!userId || pendingSave) return
+ setPendingSave(row.opportunity_id)
+ const isSaved = Boolean(stateMap.get(row.opportunity_id)?.saved_at)
+ const result = await opportunityRepository.setSaved(
+ userId,
+ row.opportunity_id,
+ row.opportunity_version_id,
+ !isSaved,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await loadDiscovered()
+ setPendingSave(null)
+ }
+
+ async function toggleHide(row: OpportunitySearchRow) {
+ if (!userId || pendingSave) return
+ setPendingSave(row.opportunity_id)
+ const isHidden = Boolean(stateMap.get(row.opportunity_id)?.hidden_at)
+ const result = await opportunityRepository.setHidden(
+ userId,
+ row.opportunity_id,
+ !isHidden,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await loadDiscovered()
+ setPendingSave(null)
+ }
+
+ async function toggleDismiss(row: PrivateOpportunity) {
+ const result = await privateOpportunityRepository.setDismissed(
+ row.id,
+ !row.dismissed_at,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await loadManual()
+ }
+
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
+
+ return (
+
+ Opportunities
+ {message && {message}
}
+
+ setTab('discovered')}
+ >
+ Discovered
+
+ setTab('manual')}
+ >
+ Manual
+
+ Add manually
+
+
+ {tab === 'discovered' && (
+
+
{
+ event.preventDefault()
+ setPage(0)
+ void loadDiscovered()
+ }}
+ >
+ Search
+ setSearch(event.target.value)}
+ placeholder="Title, organization, description, location"
+ />
+ Kind
+ updateFilter(setKind, event.target.value)}
+ >
+ Any kind
+ {opportunityKindOptions.map((value) => (
+
+ {opportunityKindLabels[value]}
+
+ ))}
+
+ Employment type
+
+ updateFilter(setEmploymentType, event.target.value)
+ }
+ >
+ Any employment type
+ {employmentTypeOptions.map((value) => (
+
+ {employmentTypeLabels[value]}
+
+ ))}
+
+ Remote mode
+
+ updateFilter(setRemoteMode, event.target.value)
+ }
+ >
+ Any remote mode
+ {remoteModeOptions.map((value) => (
+
+ {remoteModeLabels[value]}
+
+ ))}
+
+ Source
+
+ updateFilter(setSourceKey, event.target.value)
+ }
+ >
+ Any source
+ {sources.map((source) => (
+
+ {source.display_name}
+
+ ))}
+
+ Status
+
+ updateFilter(setLifecycleStatus, event.target.value)
+ }
+ >
+ Any status
+ Active
+ Stale
+ Closed
+ Unknown
+
+ Location
+
+ updateFilter(setLocationText, event.target.value)
+ }
+ placeholder="e.g. Bremen"
+ />
+ {locationPresets.map((preset) => (
+ {
+ updateFilter(setLocationText, preset)
+ void loadDiscovered()
+ }}
+ >
+ {preset}
+
+ ))}
+
+ Location matches plain text only -- there is no geocoding or
+ structured location normalization yet, so unusual spellings may
+ not match.
+
+ Sort
+
+ setSort(event.target.value as OpportunitySort)
+ }
+ >
+ Recently discovered
+ Recently posted
+ Deadline soon
+ Organization
+ Title
+
+ Search
+
+
+
+ updateFilter(setShowHidden, event.target.checked)
+ }
+ />{' '}
+ Show hidden
+
+
+
+ updateFilter(setSavedOnly, event.target.checked)
+ }
+ />{' '}
+ Saved only
+
+
+
+ updateFilter(setAppliedOnly, event.target.checked)
+ }
+ />{' '}
+ Applied only
+
+
+ {discoveredStatus === 'loading' &&
Loading…
}
+ {discoveredStatus === 'error' && (
+
+
Opportunities could not be loaded.
+
void loadDiscovered()}>
+ Retry
+
+
+ )}
+ {discoveredStatus === 'ready' && rows.length === 0 && (
+
No opportunities match your filters.
+ )}
+ {discoveredStatus === 'ready' && rows.length > 0 && (
+
+ {rows.map((row) => {
+ const state = stateMap.get(row.opportunity_id)
+ const isSaved = Boolean(state?.saved_at)
+ const isHidden = Boolean(state?.hidden_at)
+ const isApplied = appliedOpportunityIds.has(row.opportunity_id)
+ return (
+
+
+ {row.title}
+ {' '}
+ at {row.organization}
+
+ {row.source_display_name} ·{' '}
+ {opportunityKindLabels[row.opportunity_kind]} ·{' '}
+ {remoteModeLabels[row.remote_mode]}
+ {row.location_text && (
+ <>
+ {' '}
+ · {row.location_text}
+ >
+ )}
+
+
+ Discovered {formatDate(row.first_discovered_at)}
+
+ {row.application_deadline && (
+ <>
+ {' '}
+ ·{' '}
+
+ Deadline {formatDate(row.application_deadline)}
+
+ >
+ )}
+ {row.lifecycle_status !== 'active' && (
+ <>
+ {' '}
+ ({row.lifecycle_status})
+ >
+ )}
+ {isSaved && · Saved }
+ {isApplied && · Applied }
+ {isHidden && · Hidden }
+
+ void toggleSave(row)}
+ >
+ {isSaved ? 'Unsave' : 'Save'}
+
+ void toggleHide(row)}
+ >
+ {isHidden ? 'Unhide' : 'Hide'}
+
+
+ )
+ })}
+
+ )}
+
+ setPage((current) => Math.max(0, current - 1))}
+ >
+ Previous
+
+
+ {' '}
+ Page {page + 1} of {totalPages}{' '}
+
+ = totalPages}
+ onClick={() => setPage((current) => current + 1)}
+ >
+ Next
+
+
+
+ )}
+
+ {tab === 'manual' && (
+
+
+ setShowDismissed(event.target.checked)}
+ />{' '}
+ Show hidden
+
+ {manualStatus === 'loading' &&
Loading…
}
+ {manualStatus === 'error' && (
+
+
Manual opportunities could not be loaded.
+
void loadManual()}>
+ Retry
+
+
+ )}
+ {manualStatus === 'ready' && manualRows.length === 0 && (
+
+ No manually added opportunities yet.{' '}
+ Add one.
+
+ )}
+ {manualStatus === 'ready' && manualRows.length > 0 && (
+
+ {manualRows.map((row) => (
+
+
+ {row.title}
+ {' '}
+ at {row.organization_name}
+
+ Added by you ·{' '}
+ {opportunityKindLabels[row.opportunity_kind]}
+ {row.location_text && (
+ <>
+ {' '}
+ · {row.location_text}
+ >
+ )}
+ {row.dismissed_at && · Hidden }
+
+ void toggleDismiss(row)}
+ >
+ {row.dismissed_at ? 'Unhide' : 'Hide'}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/app/src/pages/PrivateOpportunityDetailPage.test.tsx b/app/src/pages/PrivateOpportunityDetailPage.test.tsx
new file mode 100644
index 0000000..a5f7d6c
--- /dev/null
+++ b/app/src/pages/PrivateOpportunityDetailPage.test.tsx
@@ -0,0 +1,191 @@
+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 { PrivateOpportunityDetailPage } from './PrivateOpportunityDetailPage'
+
+const get = vi.fn()
+const update = vi.fn()
+const setDismissed = vi.fn()
+const remove = vi.fn()
+const getByPrivateOpportunity = vi.fn()
+const createForPrivateOpportunity = vi.fn()
+const navigate = vi.fn()
+
+vi.mock('../contexts/AuthContext', () => ({
+ useAuth: () => ({ session: { user: { id: 'user-1' } } }),
+}))
+vi.mock('react-router', async () => {
+ const actual =
+ await vi.importActual('react-router')
+ return { ...actual, useNavigate: () => navigate }
+})
+vi.mock('../lib/privateOpportunityRepository', () => ({
+ privateOpportunityRepository: {
+ get: (...args: unknown[]) => get(...args),
+ update: (...args: unknown[]) => update(...args),
+ setDismissed: (...args: unknown[]) => setDismissed(...args),
+ remove: (...args: unknown[]) => remove(...args),
+ },
+}))
+vi.mock('../lib/applicationRepository', () => ({
+ applicationRepository: {
+ getByPrivateOpportunity: (...args: unknown[]) =>
+ getByPrivateOpportunity(...args),
+ createForPrivateOpportunity: (...args: unknown[]) =>
+ createForPrivateOpportunity(...args),
+ },
+}))
+
+const entry = {
+ id: 'priv-1',
+ user_id: 'user-1',
+ source_url: 'https://jobs.fraunhofer.de/example',
+ title: 'Research Assistant',
+ organization_name: 'Fraunhofer IWES',
+ location_text: 'Bremen, Germany',
+ opportunity_kind: 'research_assistant' as const,
+ employment_type: 'part_time' as const,
+ remote_mode: 'unknown' as const,
+ description_text: 'Support the sensor team.',
+ posted_at: null,
+ application_deadline: null,
+ application_url: null,
+ dismissed_at: null,
+ created_at: '2026-08-01T00:00:00Z',
+ updated_at: '2026-08-01T00:00:00Z',
+}
+
+function renderPage() {
+ return render(
+
+
+ }
+ />
+
+ ,
+ )
+}
+
+afterEach(() => vi.resetAllMocks())
+
+describe('PrivateOpportunityDetailPage', () => {
+ it('asks for confirmation before deleting, and does not delete when cancelled', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ expect(confirmSpy).toHaveBeenCalled()
+ expect(remove).not.toHaveBeenCalled()
+ confirmSpy.mockRestore()
+ })
+
+ it('shows a missing state for a deleted manual opportunity', async () => {
+ get.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(await screen.findByText(/no longer exists/i)).toBeInTheDocument()
+ })
+
+ it('renders the manual entry as "Added by you"', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ renderPage()
+ expect(
+ await screen.findByRole('heading', { name: 'Research Assistant' }),
+ ).toBeInTheDocument()
+ expect(screen.getByText('Added by you')).toBeInTheDocument()
+ expect(screen.getByText('Support the sensor team.')).toBeInTheDocument()
+ })
+
+ it('edits and saves the manual opportunity in place', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ update.mockResolvedValue({
+ data: { ...entry, title: 'Senior Research Assistant' },
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Edit' }))
+ const titleInput = screen.getByLabelText('Title')
+ await user.clear(titleInput)
+ await user.type(titleInput, 'Senior Research Assistant')
+ await user.click(screen.getByRole('button', { name: 'Save changes' }))
+ expect(update).toHaveBeenCalledWith(
+ 'priv-1',
+ expect.objectContaining({ title: 'Senior Research Assistant' }),
+ )
+ expect(
+ await screen.findByRole('heading', { name: 'Senior Research Assistant' }),
+ ).toBeInTheDocument()
+ })
+
+ it('lets the owner correct source_url without touching other fields', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ update.mockResolvedValue({
+ data: { ...entry, source_url: 'https://jobs.fraunhofer.de/corrected' },
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Edit' }))
+ const sourceUrlInput = screen.getByLabelText('Source URL')
+ await user.clear(sourceUrlInput)
+ await user.type(sourceUrlInput, 'https://jobs.fraunhofer.de/corrected')
+ await user.click(screen.getByRole('button', { name: 'Save changes' }))
+ expect(update).toHaveBeenCalledWith(
+ 'priv-1',
+ expect.objectContaining({
+ source_url: 'https://jobs.fraunhofer.de/corrected',
+ }),
+ )
+ })
+
+ it('hides the manual opportunity', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ setDismissed.mockResolvedValue({ data: {}, error: null })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Hide' }))
+ expect(setDismissed).toHaveBeenCalledWith('priv-1', true)
+ })
+
+ it('deletes the manual opportunity and navigates back to the list', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ remove.mockResolvedValue({ data: [{ id: 'priv-1' }], error: null })
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ expect(remove).toHaveBeenCalledWith('priv-1')
+ expect(navigate).toHaveBeenCalledWith('/opportunities', { replace: true })
+ confirmSpy.mockRestore()
+ })
+
+ it('starts an application from the manual opportunity', async () => {
+ get.mockResolvedValue({ data: entry, error: null })
+ getByPrivateOpportunity.mockResolvedValue({ data: null, error: null })
+ createForPrivateOpportunity.mockResolvedValue({
+ data: { id: 'app-1' },
+ error: null,
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await screen.findByRole('heading', { name: 'Research Assistant' })
+ await user.click(screen.getByRole('button', { name: 'Start application' }))
+ expect(createForPrivateOpportunity).toHaveBeenCalledWith('user-1', 'priv-1')
+ expect(navigate).toHaveBeenCalledWith('/applications/app-1')
+ })
+})
diff --git a/app/src/pages/PrivateOpportunityDetailPage.tsx b/app/src/pages/PrivateOpportunityDetailPage.tsx
new file mode 100644
index 0000000..98bea53
--- /dev/null
+++ b/app/src/pages/PrivateOpportunityDetailPage.tsx
@@ -0,0 +1,463 @@
+import { Link, useNavigate, useParams } from 'react-router'
+import { useCallback, useEffect, useState, type FormEvent } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+import { privateOpportunityRepository } from '../lib/privateOpportunityRepository'
+import { applicationRepository } from '../lib/applicationRepository'
+import {
+ employmentTypeLabels,
+ employmentTypeOptions,
+ opportunityKindLabels,
+ opportunityKindOptions,
+ remoteModeLabels,
+ remoteModeOptions,
+ type EmploymentType,
+ type OpportunityKind,
+ type PrivateOpportunity,
+ type RemoteMode,
+} from '../lib/opportunityTypes'
+import { errorMessage, safeError } from '../lib/profileTypes'
+
+type FormValues = {
+ source_url: string
+ title: string
+ organization_name: string
+ location_text: string
+ opportunity_kind: OpportunityKind
+ employment_type: EmploymentType
+ remote_mode: RemoteMode
+ description_text: string
+ posted_at: string
+ application_deadline: string
+ application_url: string
+}
+
+function toFormValues(entry: PrivateOpportunity): FormValues {
+ return {
+ source_url: entry.source_url,
+ title: entry.title,
+ organization_name: entry.organization_name,
+ location_text: entry.location_text,
+ opportunity_kind: entry.opportunity_kind,
+ employment_type: entry.employment_type,
+ remote_mode: entry.remote_mode,
+ description_text: entry.description_text ?? '',
+ posted_at: entry.posted_at?.slice(0, 10) ?? '',
+ application_deadline: entry.application_deadline?.slice(0, 10) ?? '',
+ application_url: entry.application_url ?? '',
+ }
+}
+
+export function PrivateOpportunityDetailPage() {
+ const { privateOpportunityId } = useParams()
+ const { session } = useAuth()
+ const userId = session?.user.id
+ const navigate = useNavigate()
+
+ const [status, setStatus] = useState<
+ 'loading' | 'ready' | 'missing' | 'error'
+ >('loading')
+ const [entry, setEntry] = useState(null)
+ const [applicationId, setApplicationId] = useState(null)
+ const [editing, setEditing] = useState(false)
+ const [values, setValues] = useState(null)
+ const [errors, setErrors] = useState>({})
+ const [message, setMessage] = useState(null)
+ const [pending, setPending] = useState(false)
+
+ const load = useCallback(async () => {
+ if (!privateOpportunityId || !userId) return
+ setStatus('loading')
+ const result = await privateOpportunityRepository.get(privateOpportunityId)
+ if (result.error) {
+ setStatus('error')
+ return
+ }
+ if (!result.data) {
+ setStatus('missing')
+ return
+ }
+ setEntry(result.data)
+ setValues(toFormValues(result.data))
+ const appResult = await applicationRepository.getByPrivateOpportunity(
+ userId,
+ privateOpportunityId,
+ )
+ setApplicationId(appResult.data?.id ?? null)
+ setStatus('ready')
+ }, [privateOpportunityId, userId])
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- asynchronous initial load updates state after the repository resolves
+ void load()
+ }, [load])
+
+ function field(name: K, value: string) {
+ setValues((current) => (current ? { ...current, [name]: value } : current))
+ }
+
+ function validate(current: FormValues) {
+ const nextErrors: Record = {}
+ if (!current.source_url.trim())
+ nextErrors.source_url = 'Source URL is required.'
+ else if (!current.source_url.startsWith('https://'))
+ nextErrors.source_url = 'Source URL must start with https://.'
+ if (!current.title.trim()) nextErrors.title = 'Title is required.'
+ if (!current.organization_name.trim())
+ nextErrors.organization_name = 'Organization is required.'
+ if (!current.location_text.trim())
+ nextErrors.location_text = 'Location is required.'
+ if (
+ current.application_url &&
+ !current.application_url.startsWith('https://')
+ )
+ nextErrors.application_url = 'Application URL must start with https://.'
+ return nextErrors
+ }
+
+ async function save(event: FormEvent) {
+ event.preventDefault()
+ if (!entry || !values) return
+ const nextErrors = validate(values)
+ setErrors(nextErrors)
+ if (Object.keys(nextErrors).length || pending) return
+ setPending(true)
+ const result = await privateOpportunityRepository.update(entry.id, {
+ source_url: values.source_url.trim(),
+ title: values.title.trim(),
+ organization_name: values.organization_name.trim(),
+ location_text: values.location_text.trim(),
+ opportunity_kind: values.opportunity_kind,
+ employment_type: values.employment_type,
+ remote_mode: values.remote_mode,
+ description_text: values.description_text.trim() || null,
+ posted_at: values.posted_at || null,
+ application_deadline: values.application_deadline || null,
+ application_url: values.application_url.trim() || null,
+ })
+ if (result.error || !result.data) {
+ setMessage(errorMessage(safeError(result.error)))
+ setPending(false)
+ return
+ }
+ setEntry(result.data)
+ setValues(toFormValues(result.data))
+ setEditing(false)
+ setPending(false)
+ setMessage('Saved.')
+ }
+
+ async function toggleDismiss() {
+ if (!entry || pending) return
+ setPending(true)
+ const result = await privateOpportunityRepository.setDismissed(
+ entry.id,
+ !entry.dismissed_at,
+ )
+ if (result.error) setMessage(errorMessage(safeError(result.error)))
+ else await load()
+ setPending(false)
+ }
+
+ async function remove() {
+ if (!entry || pending) return
+ if (
+ !window.confirm(
+ 'Delete this manual opportunity? This does not delete any existing application for it -- the application keeps its own pinned snapshot.',
+ )
+ )
+ return
+ setPending(true)
+ const result = await privateOpportunityRepository.remove(entry.id)
+ if (result.error) {
+ setMessage(errorMessage(safeError(result.error)))
+ setPending(false)
+ return
+ }
+ navigate('/opportunities', { replace: true })
+ }
+
+ async function startApplication() {
+ if (!entry || !userId || pending) return
+ setPending(true)
+ const result = await applicationRepository.createForPrivateOpportunity(
+ userId,
+ entry.id,
+ )
+ if (result.error) {
+ if (result.error.code === '23505') {
+ const existing = await applicationRepository.getByPrivateOpportunity(
+ userId,
+ entry.id,
+ )
+ if (existing.data) {
+ navigate(`/applications/${existing.data.id}`)
+ return
+ }
+ }
+ setMessage(errorMessage(safeError(result.error)))
+ setPending(false)
+ return
+ }
+ navigate(`/applications/${result.data!.id}`)
+ }
+
+ if (status === 'loading')
+ return (
+
+ Manual opportunity
+ Loading…
+
+ )
+ if (status === 'missing')
+ return (
+
+ Manual opportunity
+ This opportunity no longer exists.
+ Return to opportunities
+
+ )
+ if (status === 'error' || !entry || !values)
+ return (
+
+ Manual opportunity
+ This opportunity could not be loaded.
+ void load()}>
+ Retry
+
+
+ )
+
+ if (editing)
+ return (
+
+ Edit manual opportunity
+
+
+
Source URL
+
field('source_url', event.target.value)}
+ aria-invalid={Boolean(errors.source_url)}
+ />
+ {errors.source_url &&
{errors.source_url}
}
+
+ Correcting this never changes an already-captured application
+ snapshot -- that stays exactly as it was when you applied.
+
+
+
+
Title
+
field('title', event.target.value)}
+ aria-invalid={Boolean(errors.title)}
+ />
+ {errors.title &&
{errors.title}
}
+
+
+
Organization
+
+ field('organization_name', event.target.value)
+ }
+ aria-invalid={Boolean(errors.organization_name)}
+ />
+ {errors.organization_name && (
+
{errors.organization_name}
+ )}
+
+
+
Location
+
field('location_text', event.target.value)}
+ aria-invalid={Boolean(errors.location_text)}
+ />
+ {errors.location_text &&
{errors.location_text}
}
+
+
+ Kind
+
+ field('opportunity_kind', event.target.value)
+ }
+ >
+ {opportunityKindOptions.map((value) => (
+
+ {opportunityKindLabels[value]}
+
+ ))}
+
+
+
+ Employment type
+ field('employment_type', event.target.value)}
+ >
+ {employmentTypeOptions.map((value) => (
+
+ {employmentTypeLabels[value]}
+
+ ))}
+
+
+
+ Remote mode
+ field('remote_mode', event.target.value)}
+ >
+ {remoteModeOptions.map((value) => (
+
+ {remoteModeLabels[value]}
+
+ ))}
+
+
+
+
+ Description / requirements
+
+
+ field('description_text', event.target.value)
+ }
+ />
+
+
+ Posted date (if known)
+ field('posted_at', event.target.value)}
+ />
+
+
+
+ Deadline (if known)
+
+
+ field('application_deadline', event.target.value)
+ }
+ />
+
+
+
Application URL
+
field('application_url', event.target.value)}
+ aria-invalid={Boolean(errors.application_url)}
+ />
+ {errors.application_url && (
+
{errors.application_url}
+ )}
+
+
+ {pending ? 'Saving…' : 'Save changes'}
+
+ {
+ setValues(toFormValues(entry))
+ setErrors({})
+ setEditing(false)
+ }}
+ >
+ Cancel
+
+
+ {message && {message}
}
+
+ )
+
+ return (
+
+ {entry.title}
+ Added by you
+
+ {entry.organization_name} ·{' '}
+ {opportunityKindLabels[entry.opportunity_kind]} ·{' '}
+ {employmentTypeLabels[entry.employment_type]} ·{' '}
+ {remoteModeLabels[entry.remote_mode]}
+
+ {entry.location_text}
+ {entry.description_text && (
+
+ Description
+ {entry.description_text}
+
+ )}
+
+
+ Open original source listing
+
+
+ {entry.application_url && (
+
+
+ Open application page
+
+
+ )}
+ {message && {message}
}
+ setEditing(true)}>
+ Edit
+
+ void toggleDismiss()}
+ >
+ {entry.dismissed_at ? 'Unhide' : 'Hide'}
+
+ void remove()}
+ >
+ Delete
+
+ {applicationId ? (
+ View application
+ ) : (
+ void startApplication()}
+ >
+ Start application
+
+ )}
+
+ Back to opportunities
+
+
+ )
+}
diff --git a/app/src/pages/ProfileLayout.tsx b/app/src/pages/ProfileLayout.tsx
index 6872673..2e571ad 100644
--- a/app/src/pages/ProfileLayout.tsx
+++ b/app/src/pages/ProfileLayout.tsx
@@ -1,30 +1,8 @@
import { NavLink, Outlet } from 'react-router'
-import { useState } from 'react'
-import { useAuth } from '../contexts/AuthContext'
export function ProfileLayout() {
- const { signOut } = useAuth()
- const [error, setError] = useState(null)
-
- async function handleSignOut() {
- setError(null)
- const result = await signOut()
- if (result.error) setError(result.error)
- }
-
return (
-
-
- CareerOS
- void handleSignOut()}
- >
- Sign out
-
-
- {error && {error}
}
+
Overview
@@ -34,6 +12,6 @@ export function ProfileLayout() {
Experience
-
+
)
}
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index ed5be95..3e258ea 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -184,19 +184,35 @@ versions** carrying the actual historical content. See
[ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md) for the full
reasoning.
+**Implementation status and scope note (Phase 2A.1)**: with exactly one adapter (Greenhouse)
+enabled, cross-source deduplication — `potential_duplicate_links`, the strong/weak-match linking
+rule, `link_confidence`, `opportunities.merged_into_opportunity_id` — is **not implemented**. There
+is no second source that could ever produce a match, so that code would be unexercised and
+untestable. `source_listings.opportunity_id` is therefore `not null` (one listing maps to exactly
+one opportunity today, a simplification of the table below). Revisit when a second adapter is
+proposed, per ADR-010's own scope note. All writes to this domain go through three narrow,
+trusted-only Postgres functions (`begin_ingestion_run`, `apply_source_listing`,
+`finalize_ingestion_run`) rather than direct table grants — see
+[RLS_POLICY_MATRIX.md](RLS_POLICY_MATRIX.md).
+
| Table | Key fields | Notes |
|---|---|---|
-| `sources` | `id`, `name`, `adapter_key`, `base_url`, `legal_basis_notes`, `rate_limit_config`, `enabled` | Registry of configured adapters; see [ADR-004](adr/ADR-004-source-adapter-architecture.md) |
-| `source_listings` | `id`, `source_id`, `external_id`, `canonical_source_url`, `first_seen_at`, `last_seen_at`, `status` (`active`\|`removed`), `opportunity_id` (FK, nullable until linked), `link_confidence` (`exact`\|`strong_deterministic`\|`needs_review`\|`unlinked`) | One row per (source, external_id) — this is what an adapter actually observed. Never holds descriptive content itself; content lives in `opportunity_versions` |
-| `opportunities` | `id`, `status` (`active`\|`removed`\|`needs_review`\|`merged_duplicate`), `first_discovered_at`, `last_checked_at`, `current_version_id`, `merged_into_opportunity_id` (nullable, set only when `status = merged_duplicate`) | The **canonical, real-world opportunity**. Holds identity, lifecycle, and timestamps only — no descriptive content. One or more `source_listings` may point to the same `opportunities` row |
-| `opportunity_versions` | see full field list below | Immutable historical snapshot. Tied to the specific `source_listing_id` whose capture produced it, and to the canonical `opportunity_id` |
-| `potential_duplicate_links` | `id`, `opportunity_id_a`, `opportunity_id_b`, `match_basis_jsonb`, `match_confidence`, `status` (`pending_review`\|`confirmed_duplicate`\|`rejected`), `created_at`, `reviewed_at` | Uncertain cross-source matches that were **not** auto-merged; see dedup rules below |
-| `ingestion_runs` | `id`, `source_id`, `started_at`, `finished_at`, `status`, `records_found`, `records_new`, `records_updated`, `records_removed`, `error_summary` | Operational log, also drives [OBSERVABILITY.md](OBSERVABILITY.md) |
+| `sources` | `id`, `source_key` (unique, e.g. `greenhouse:helsing`), `adapter_kind` (non-unique, e.g. `greenhouse`), `display_name`, `base_url`, `enabled` | Registry of configured, reviewed adapters/boards; see [ADR-004](adr/ADR-004-source-adapter-architecture.md). `source_key` and `adapter_kind` are deliberately distinct: a single adapter serves multiple boards, each its own `source_key` |
+| `source_listings` | `id`, `source_id`, `external_id`, `canonical_source_url`, `application_url`, `first_seen_at`, `last_seen_at`, `status` (`active`\|`removed`), `absence_count`, `opportunity_id` (FK, not null) | One row per (source, external_id) — this is what an adapter actually observed. Never holds descriptive content itself; content lives in `opportunity_versions` |
+| `opportunities` | `id`, `status` (`active`\|`stale`\|`closed`\|`unknown`), `first_discovered_at`, `last_checked_at`, `current_version_id` | The **canonical, real-world opportunity**. Holds identity, lifecycle, and timestamps only — no descriptive content |
+| `opportunity_versions` | see full field list below | Immutable historical snapshot. Tied to the specific `source_listing_id` whose capture produced it, and to the canonical `opportunity_id`. Insert-only at the grant level for every role, including service_role |
+| `ingestion_runs` | `id`, `source_id`, `started_at`, `finished_at`, `status` (`running`\|`complete`\|`partial`\|`failed`), `completeness` (`complete`\|`partial`, null while running/failed), `dry_run`, `records_found`, `records_new`, `records_updated`, `records_closed`, `error_count`, `error_summary` (jsonb, capped) | Operational log, one row per CLI invocation |
+| `opportunity_search` (view) | `opportunity_id`, `opportunity_version_id`, `lifecycle_status`, `first_discovered_at`, `last_checked_at`, `source_id`, `source_key`, `source_display_name`, `source_listing_id`, `canonical_source_url`, `title`, `organization`, `description`, `location_text`, `country`, `region`, `city`, `opportunity_kind`, `employment_type`, `remote_mode`, `posted_at`, `application_deadline`, `application_url`, `version_captured_at` | Read model for browsing: one row per opportunity, joined to its current version and provenance. Browser SELECT-only |
-### `opportunity_versions` — complete field list
+### `opportunity_versions` — implemented field list
Every mutable, historically-meaningful fact about a posting lives here, never only on the stable
-`opportunities` row:
+`opportunities` row. Phase 2A.1 implements a practical subset of the originally sketched field
+list — no `category`/`subtype`/career-or-development-specific JSONB (that taxonomy exists to drive
+Ranking 1/2, out of scope here — see [OPPORTUNITY_TAXONOMY.md](OPPORTUNITY_TAXONOMY.md)), no
+separate `sanitized_display_content` (the single `description` field is already the sanitized,
+render-safe text), no `responsibilities`/`required_qualifications`/`preferred_qualifications` split
+(folded into `description`), no `parser_version`/`normalizer_version`:
| Field | Purpose |
|---|---|
@@ -205,30 +221,18 @@ Every mutable, historically-meaningful fact about a posting lives here, never on
| `source_listing_id` | Which source listing's capture produced this version (provenance) |
| `version_number` | Sequential per opportunity |
| `captured_at` | When this snapshot was taken |
-| `title` | |
-| `organization` | Company/institution name |
-| `description` | Normalized, sanitized plain-text/limited-markup body |
-| `responsibilities` | Structured or free-text, source-dependent |
-| `required_qualifications` | |
-| `preferred_qualifications` | |
-| `location` | |
-| `remote_status` | `onsite` \| `hybrid` \| `remote` |
-| `deadline` | Nullable — many postings don't state one |
+| `title`, `organization`, `description` | Sanitized plain text |
+| `location_text`, `country`, `region`, `city` | |
+| `remote_mode` | `onsite` \| `hybrid` \| `remote` \| `unknown` |
+| `opportunity_kind` | Practical trimmed enum: `internship`, `working_student`, `graduate_program`, `entry_level`, `research_assistant`, `phd`, `scholarship`, `hackathon`, `fellowship`, `other` — shared with `private_opportunities` |
+| `employment_type` | `full_time`, `part_time`, `contract`, `temporary`, `internship`, `volunteer`, `other` — shared with `private_opportunities` |
| `application_url` | The URL to actually apply, which may differ from `source_listings.canonical_source_url` |
-| `category` | `career` \| `development` — versioned because a posting's classification can be corrected between captures |
-| `subtype` | e.g. `internship`, `hackathon` — versioned for the same reason |
-| `career_specific_fields` | JSONB: employment type, duration, compensation notes, etc. — only populated when `category = career` |
-| `development_specific_fields` | JSONB: cost, time commitment, produces-artifact flag, event dates, etc. — only populated when `category = development` |
-| `sanitized_display_content` | The exact sanitized content the frontend is permitted to render (see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)); kept distinct from `description` so rendering and scoring can evolve independently |
-| `source_metadata` | JSONB: source-specific fields not mapped into the common schema, retained for future remapping/debugging — sanitized, never raw HTML (see §"Raw external-content retention" below) |
+| `posted_at`, `application_deadline`, `source_updated_at` | Nullable — not every source states these |
| `content_hash` | Used for idempotent change detection |
-| `parser_version` | Which adapter/normalizer code version produced this version |
-| `normalizer_version` | Same purpose, for the shared normalization step, when it differs from the adapter's own version |
+| `source_metadata` | JSONB, bounded (≤ 8 KB): source-specific fields not mapped into the common schema — sanitized, never raw HTML (see §"Raw external-content retention" below) |
-`opportunities.current_version_id` always points at the version currently treated as the
-"live" content for that opportunity (ordinarily the latest by `captured_at`, but see the
-duplicate-resolution note in [ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md)
-for how this is chosen when multiple source listings feed one opportunity).
+`opportunities.current_version_id` always points at the version currently treated as the "live"
+content for that opportunity — the latest by `version_number`, set by `apply_source_listing`.
## Scoring domain (shared source data, but scores are computed per user where applicable)
@@ -252,13 +256,18 @@ distinct, timestamped event, not a mutation of the number the user already saw.
## User activity domain (user-owned, RLS-protected)
+**Implementation status (Phase 2A.1)**: `user_opportunity_state`, `applications`, `interview_prep_notes`,
+and `private_opportunities` are implemented. `tasks` remains a logical target only — MVP_SCOPE.md's
+"simple tasks associated with opportunities" is satisfied at Phase 2A.1 by `applications.next_action`/
+`next_action_due_at` directly; a standalone `tasks` table (including deadline-derived tasks that exist
+before any application) is deferred to a later pass.
+
| Table | Key fields | Notes |
|---|---|---|
-| `saved_opportunities` | `id`, `user_id`, `opportunity_id`, `saved_opportunity_version_id`, `notes`, `saved_at` | Snapshot pinning: even if the opportunity gets new versions later, this remembers which version — and, transitively via that version's `source_listing_id`, which source content — the user actually saved and reacted to |
-| `applications` | `id`, `user_id`, `saved_opportunity_id` (nullable FK to `saved_opportunities`, set for a shared/ingested opportunity), `private_opportunity_id` (nullable FK to `private_opportunities`, set for a manually entered one — exactly one of the two is set), `private_opportunity_snapshot_jsonb` (populated only when `private_opportunity_id` is set — see note below), `status` (`preparing`\|`applied`\|`awaiting_response`\|`interview_scheduled`\|`interview_complete`\|`offer`\|`accepted`\|`rejected`\|`withdrawn`\|`closed`), `applied_at`, `status_updated_at`, `next_action` (nullable), `next_action_due_at` (nullable), `notes`, `contact_note` (nullable), `resume_id_used` | Current status plus timestamps only — no event-sourced status-history table at MVP (see [MVP_SCOPE.md](MVP_SCOPE.md#current-product-mode-and-engineering-priority)); "saved" is a state prior to and outside this enum, held on `saved_opportunities`/`private_opportunities` |
-| `tasks` | `id`, `user_id`, `application_id` (nullable), `opportunity_id` (nullable), `title`, `due_date`, `status` (`open`\|`done`), `origin` (`manual`\|`suggested`) | Deadline-derived tasks reference the opportunity directly even before an application exists |
-| `interview_prep_notes` | `id`, `application_id`, `content`, `created_at` | Free-text notes tied to the application — likely questions, topics to revise, recruiter notes, post-interview reflections all fit in `content`; no structured sub-fields at MVP |
-| `private_opportunities` | `id`, `user_id`, `source_url`, `title`, `organization_name`, `location_text`, `opportunity_kind`, `employment_type`, `remote_mode`, `description_text`, `posted_at`, `application_deadline`, `application_url`, `dismissed_at`, `promoted_to_opportunity_id` (nullable, trusted-write only), `created_at`, `updated_at`, `last_confirmed_at` | **New (Phase 2A, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, structurally and permission-wise separate from the shared identity domain above — not written through the ingestion/dedup pipeline, not versioned like `opportunity_versions` (ordinary mutable CRUD, like `work_experience`), private by default. `promoted_to_opportunity_id` is a future, trusted-only, additive link to a canonical shared opportunity if one is later discovered by an adapter; no promotion logic exists yet. Exact column types/constraints are finalized at Phase 2A.1 implementation time |
+| `user_opportunity_state` | `id`, `user_id`, `opportunity_id`, `saved_at` (nullable), `saved_opportunity_version_id` (nullable, set exactly when `saved_at` is), `hidden_at` (nullable), `notes`, `created_at`, `updated_at` | **Implemented name for what an earlier draft of this document called `saved_opportunities`** — resolved to the name [ADR-018](adr/ADR-018-private-manual-opportunities.md) itself uses when contrasting this table with `private_opportunities.dismissed_at`. One row per `(user_id, opportunity_id)`; a "neither saved nor hidden" state has no row at all (unsave+unhide deletes it). Applies only to shared `opportunities` — a private manual opportunity's existence already means it's saved, and `dismissed_at` is its hide equivalent. Snapshot pinning: `saved_opportunity_version_id` remembers which version the user actually saved and reacted to, even after the opportunity gets new versions later |
+| `applications` | `id`, `user_id`, `shared_opportunity_id` (nullable FK to `opportunities`, set and immutable for a shared application — see note below), `opportunity_version_id` (nullable FK to `opportunity_versions`, set and pinned for a shared/ingested opportunity), `private_opportunity_id` (nullable FK to `private_opportunities`, `on delete set null`, set for a manually entered one), `private_opportunity_snapshot` (jsonb, populated only on the manual path — see note below), `status` (`preparing`\|`applied`\|`awaiting_response`\|`interview_scheduled`\|`interview_complete`\|`offer`\|`accepted`\|`rejected`\|`withdrawn`\|`closed`), `applied_at`, `status_updated_at`, `next_action` (nullable), `next_action_due_at` (nullable), `notes`, `contact_note` (nullable) | Current status plus timestamps only — no event-sourced status-history table at MVP (see [MVP_SCOPE.md](MVP_SCOPE.md#current-product-mode-and-engineering-priority)). Exactly one of `opportunity_version_id`/`private_opportunity_snapshot` is set, enforced by both a check constraint and a trigger; the browser has no update grant on either, or on `user_id`/`created_at`, so the pinned source is immutable after creation. A partial unique index on `(user_id, shared_opportunity_id)` (and separately on `(user_id, private_opportunity_id)`) caps the MVP at one application tracker per user per listing — no application-attempt history/event sourcing. `contact_note` holds general recruiter/contact notes; interview-specific observations live on `interview_prep_notes` instead, to avoid two fields with the same purpose. `status_updated_at` advances only when `status` actually changes; an ordinary note edit only advances `updated_at` |
+| `interview_prep_notes` | `id`, `application_id` (unique — one row per application), `user_id` (denormalized, server-derived from the application's owner, never client-supplied), `responsibilities_to_discuss`, `required_technologies`, `topics_to_revise`, `likely_questions`, `questions_to_ask`, `interview_date`, `interview_format`, `reflections`, `created_at`, `updated_at` | Plain-text fields, not a single freeform blob, so the pinned listing snapshot and the prep notes can be shown side by side without parsing. No recruiter/contact-note field here — that lives on `applications.contact_note` |
+| `private_opportunities` | `id`, `user_id`, `source_url`, `title`, `organization_name`, `location_text`, `opportunity_kind`, `employment_type`, `remote_mode`, `description_text`, `posted_at`, `application_deadline`, `application_url`, `dismissed_at`, `promoted_to_opportunity_id` (nullable, trusted-write only), `created_at`, `updated_at`, `last_confirmed_at` | **Implemented (Phase 2A.1, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, structurally and permission-wise separate from the shared identity domain above — not written through the ingestion/dedup pipeline, not versioned like `opportunity_versions` (ordinary mutable CRUD, like `work_experience`), private by default. `opportunity_kind`/`employment_type`/`remote_mode` use the same practical enums as `opportunity_versions` (see the note below). `source_url` is ordinary mutable owner-corrected metadata (browser has an update grant on it, unlike the shared domain's provenance columns) — correcting it never touches an already-captured `applications.private_opportunity_snapshot`, which is a one-time copy taken at application creation and never re-read live. `promoted_to_opportunity_id` is a future, trusted-only, additive link to a canonical shared opportunity if one is later discovered by an adapter; no promotion logic exists yet |
## Raw external-content retention (debug-only, not a core table)
@@ -280,6 +289,17 @@ entirely to `opportunity_versions`).
latest": a user's saved reasoning and fit explanation refer to the posting as it existed when
they looked at it. If requirements change later, the user should see that explicitly as a diff,
not have their prior judgment silently reinterpreted against new text.
+- **Why a shared `applications` row carries both `shared_opportunity_id` and `opportunity_version_id`**,
+ rather than deriving "which opportunity is this?" from the pinned version alone: the two answer
+ different questions. `opportunity_version_id` must stay pinned to the exact content shown when the
+ user applied, even after `ingestion` creates newer versions — that immutability is the whole point
+ of the pinned-snapshot design above. But that same immutability means it cannot also answer "is
+ this opportunity already applied to?" after a new version becomes current, because the opportunity's
+ *current* version and the applications's *pinned* version are now different rows. `shared_opportunity_id`
+ is a second, stable FK straight to `opportunities`, derived server-side from `opportunity_version_id`
+ at insert time (never client-supplied, never rewritten) purely so the "already applied" check, the
+ duplicate-prevention unique index, and the opportunity list's "Applied" badge can all key off an
+ identity that survives re-ingestion, while `opportunity_version_id` keeps doing its original job.
- **Why `applications` carries its own `private_opportunity_snapshot_jsonb`** rather than reading
`private_opportunities` live: a shared opportunity already gets this guarantee for free through
`saved_opportunities.saved_opportunity_version_id` pointing at an immutable
diff --git a/docs/DATA_SOURCES_AND_COMPLIANCE.md b/docs/DATA_SOURCES_AND_COMPLIANCE.md
index e9201a0..ae1b1e1 100644
--- a/docs/DATA_SOURCES_AND_COMPLIANCE.md
+++ b/docs/DATA_SOURCES_AND_COMPLIANCE.md
@@ -254,8 +254,15 @@ decision. None of them are implemented as adapters yet — Bosch and Continental
highest-value target once the SmartRecruiters clarification (§3) is resolved; every other watchlist
employer is manual-import-only pending its own custom-site terms review if one is ever undertaken.
-## 7. Source registry (implementation-time; none configured yet)
+## 7. Source registry (implementation-time)
-| Source | Category | Legal basis confirmed | Rate limit | Status |
+Populated at Phase 2A.1 implementation time. All three boards below were reviewed during the
+Phase 2A.0 research pass recorded in §3 above; enabling them for real ingestion in Phase 2A.1 is
+the repository owner's explicit, direct authorization (per §5's checklist item 13 / this document's
+§3 Greenhouse entry), not an automated or default-on decision.
+
+| `source_key` | Category | Legal basis confirmed | Rate limit | Status |
|---|---|---|---|---|
-| *(none configured yet — this table is populated at Phase 2A.1 implementation time, one row per registered `sources` entry, `enabled = false` until manually verified)* | | | | |
+| `greenhouse:helsing` | Greenhouse Job Board API | See §3 Greenhouse entry above | No documented limit; CLI applies a conservative self-imposed interval/timeout regardless | `enabled = true` |
+| `greenhouse:marvelfusion` | Greenhouse Job Board API | See §3 Greenhouse entry above | Same as above | `enabled = true` |
+| `greenhouse:konux` | Greenhouse Job Board API | See §3 Greenhouse entry above | Same as above | `enabled = true` |
diff --git a/docs/INGESTION_ARCHITECTURE.md b/docs/INGESTION_ARCHITECTURE.md
index 36023e5..0621c14 100644
--- a/docs/INGESTION_ARCHITECTURE.md
+++ b/docs/INGESTION_ARCHITECTURE.md
@@ -1,19 +1,26 @@
# Ingestion Architecture
-Status: **proposal** — the architecture new source adapters and the local ingestion CLI must
-follow, per [ADR-004](adr/ADR-004-source-adapter-architecture.md) (adapter interface),
+Status: **implemented (Phase 2A.1)** for the Greenhouse adapter described below, per
+[ADR-004](adr/ADR-004-source-adapter-architecture.md) (adapter interface),
[ADR-016](adr/ADR-016-typescript-ingestion-runtime.md) (TypeScript runtime),
[ADR-017](adr/ADR-017-local-first-ingestion-execution.md) (local-first execution), and
-[ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md) (identity/dedup). No
-code exists yet — this document governs Phase 2A.1's implementation, not a description of
-something already built.
+[ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md) (identity/dedup, with
+the Phase 2A.1 single-adapter scope note recorded in
+[DATA_MODEL.md](DATA_MODEL.md#opportunity-identity-domain-sharedglobal-service-role-write)).
## 1. What exists today
-Nothing. `ingestion/` contains only a placeholder `README.md`. This document, together with the
-ADRs above and [DATA_SOURCES_AND_COMPLIANCE.md](DATA_SOURCES_AND_COMPLIANCE.md), is the design that
-Phase 2A.1 (schema, first adapter, and local CLI, shipped together — see
-[DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md)) implements against.
+`ingestion/` is an independent Node/TypeScript project implementing the Greenhouse adapter end to
+end: fetch (with retry/timeout/response-size cap), a hand-written parser, HTML-entity-decode +
+sanitize + plain-text normalization, deterministic content hashing, and a CLI (`ingest --source
+greenhouse [--board ] [--dry-run]`). All shared-domain writes go through the three trusted
+Postgres functions from the Phase 2A.1 migration (`begin_ingestion_run`, `apply_source_listing`,
+`finalize_ingestion_run`) rather than direct table access, so the pipeline described below is
+implemented as thin orchestration around those functions, not as separate upsert/versioning logic
+in TypeScript. `--dry-run` performs zero writes: it fetches, parses, normalizes, sanitizes, hashes,
+and compares against current database state via read-only queries, then reports what it would do,
+without calling any of the three trusted functions. Verified against the real, live Greenhouse API
+for all three reviewed boards (`helsing`, `marvelfusion`, `konux`) during Phase 2A.1 development.
## 2. Repository shape
@@ -25,17 +32,32 @@ Postgres schema is the contract between them (see [ADR-016](adr/ADR-016-typescri
```
ingestion/
src/
- adapters/ one module per adapter (e.g. greenhouse.ts)
- orchestrator.ts shared fetch -> normalize -> identify -> upsert -> closure pipeline
- sanitize.ts HTML sanitization (sanitize-html) + plain-text derivation
- identity.ts URL normalization, content hashing
- cli.ts entry point: `ingest`, `score` subcommands
+ adapters/
+ types.ts shared NormalizedListing/SourceRow types
+ greenhouse.ts fetch, parse, classify, normalize for the Greenhouse Job Board API
+ orchestrator.ts dry-run comparison + real apply/finalize pipeline, thin around the
+ trusted RPCs -- no separate upsert/versioning logic here
+ sanitize.ts entity-decode-once + sanitize-html allowlist + plain-text derivation
+ identity.ts URL normalization, deterministic content hashing
+ httpRetry.ts 429/5xx/network retry with backoff, honors Retry-After
+ env.ts, db.ts trusted service-role credential loading (loopback-only by default)
+ cli.ts entry point: `ingest --source greenhouse [--board] [--dry-run]`
test/
- fixtures/ synthetic, public-safe recorded-shape fixtures (never real raw responses)
+ fixtures/ synthetic, public-safe recorded-shape fixtures (never a real
+ captured response verbatim)
+ adapters/greenhouse.test.ts, orchestrator.test.ts, sanitize.test.ts, identity.test.ts
package.json
tsconfig.json
```
+Implemented scope note: no `score` subcommand exists (deterministic market-relevance scoring is
+Phase 2A.2, out of scope here) and there is no per-adapter `identify()` method as a separate
+interface method -- the Greenhouse adapter's identity fields (external ID, canonical/application
+URL) are produced directly as part of `normalizeGreenhouseJob`, since a single adapter's identity
+extraction is simple enough not to warrant a separate formal interface abstraction yet (see
+[AGENTS.md](../AGENTS.md) on avoiding premature abstraction). The interface below remains the
+target shape to converge on if and when a second adapter is added.
+
## 3. Source-adapter interface (per ADR-004)
```ts
diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md
index 650b780..39e5df0 100644
--- a/docs/RLS_POLICY_MATRIX.md
+++ b/docs/RLS_POLICY_MATRIX.md
@@ -97,25 +97,35 @@ Phase 1A migration; resume/suggestion design must be revisited with field-level
## Opportunity identity and content domain (shared/global)
+**Status (Phase 2A.1)**: implemented as described below, with all writes going through three
+narrow `SECURITY DEFINER` functions (`begin_ingestion_run`, `apply_source_listing`,
+`finalize_ingestion_run`) instead of direct table grants — none of the three are granted `EXECUTE`
+to `anon` or `authenticated`, only to `service_role`. `potential_duplicate_links` and the
+merge-confirmation function are not implemented (see the Phase 2A.1 scope note in
+[DATA_MODEL.md](DATA_MODEL.md#opportunity-identity-domain-sharedglobal-service-role-write)); revisit
+when a second adapter is added.
+
| Table | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity |
|---|---|---|---|---|---|---|---|---|---|---|
| `sources` | Shared adapter/source registry | All authenticated users | No | No | No | Full | None (shared read, service-role write) | No | Any authenticated user can read; no authenticated user can write | Low |
-| `source_listings` | Shared — one row per (source, external_id) observation | All authenticated users | No | No | No | Full | None | No | Same as above | Low |
-| `opportunities` | Shared — canonical, real-world opportunity identity/status | All authenticated users | No | **No direct grant.** The one legitimate user-triggered change (confirming a duplicate merge) goes through the `confirm_potential_duplicate(...)` `SECURITY DEFINER` function described in [ADR-010](adr/ADR-010-opportunity-identity-and-cross-source-deduplication.md), which is the only path allowed to set `status = merged_duplicate` / `merged_into_opportunity_id` | No | Full | None (shared read; writes via service-role or the one narrow function) | No | Any authenticated user can read; no authenticated user can perform a raw UPDATE/INSERT/DELETE; the merge-confirmation function only ever touches the exact row pair it's called with | Low (identity/status metadata only — no descriptive content lives here) |
-| `opportunity_versions` | Shared — immutable content snapshots | All authenticated users | No | No (never — immutability is enforced at the RLS/grant level, not just by convention; see [ADR-006](adr/ADR-006-posting-version-history.md)) | No | Insert-only (no service-role UPDATE/DELETE either, by policy, to make the immutability guarantee structural rather than merely a code convention) | None (shared read; append-only writes via service-role) | No | Any authenticated user can read; no role — including service-role — can UPDATE or DELETE an existing row; new content only ever arrives as a new row | Low–Medium (contains sanitized third-party posting text) |
-| `potential_duplicate_links` | Shared — pending/resolved duplicate-review flags | All authenticated users | No (created only by the ingestion/dedup step) | **No direct grant.** Confirm/reject goes through the same `confirm_potential_duplicate(...)` / `reject_potential_duplicate(...)` `SECURITY DEFINER` functions, which validate the row exists, is `pending_review`, and belongs to a legitimate pair before changing its status | No | Full | None (shared read; controlled write via function) | No | Any authenticated user can read the queue; no authenticated user can set `status` via a raw UPDATE — only via the review function, and only to `confirmed_duplicate`/`rejected` | Low |
-| `career_market_scores` | Shared — Ranking 1, not user-specific | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low |
-| `ingestion_runs` | Shared — operational log | All authenticated users (surfaces staleness/duplicate-queue depth per [OBSERVABILITY.md](OBSERVABILITY.md)) | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write; `error_summary` content is checked (by convention + a test) to never contain resume text or profile field values | Low (must never contain personal data — see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)) |
-| `market_skill_mentions` | Shared — aggregate market-intelligence snapshot | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low (aggregate/anonymized by construction) |
+| `source_listings` | Shared — one row per (source, external_id) observation | All authenticated users | No | No | No | Full (in practice, only ever written via `apply_source_listing`/`finalize_ingestion_run`) | None | No | Any authenticated user can read; no authenticated user can write | Low |
+| `opportunities` | Shared — canonical, real-world opportunity identity/status | All authenticated users | No | **No direct grant of any kind** — every write goes through `apply_source_listing`/`finalize_ingestion_run` | No | Full (same caveat as above) | None (shared read; writes via the trusted RPCs only) | No | Any authenticated user can read; no authenticated user can perform a raw UPDATE/INSERT/DELETE | Low (identity/status metadata only — no descriptive content lives here) |
+| `opportunity_versions` | Shared — immutable content snapshots | All authenticated users | Insert-only, and only reachable via `apply_source_listing` (no direct `authenticated` grant) | No (never — immutability is enforced at the grant level, not just by convention; see [ADR-006](adr/ADR-006-posting-version-history.md)) | No | Insert-only (no service-role UPDATE/DELETE either, by policy, to make the immutability guarantee structural rather than merely a code convention) | None (shared read; append-only writes via `apply_source_listing`) | No | Any authenticated user can read; no role — including service-role — can UPDATE or DELETE an existing row; new content only ever arrives as a new row | Low–Medium (contains sanitized third-party posting text) |
+| `ingestion_runs` | Shared — operational log | All authenticated users | No | No | No | Insert/update via `begin_ingestion_run`/`finalize_ingestion_run` only | None | No | Any authenticated user can read; no authenticated user can write; `error_summary` content is checked (by convention + a test) to never contain resume text or profile field values | Low (must never contain personal data — see [SECURITY_AND_PRIVACY.md](SECURITY_AND_PRIVACY.md)) |
+| `opportunity_search` (view) | Shared read model: current-version projection joined to source provenance | All authenticated users | N/A (view) | N/A | N/A | Full (direct table access) | None (`security_invoker`; relies on the base tables' own read-only grants) | No | Any authenticated user can read; anon is denied at the grant level | Low |
+| `potential_duplicate_links` | **Not yet implemented** (Phase 2A.1 scope note above) — proposed shape retained for when a second adapter is added: shared, pending/resolved duplicate-review flags | All authenticated users | No (created only by the ingestion/dedup step) | **No direct grant.** Confirm/reject would go through `confirm_potential_duplicate(...)` / `reject_potential_duplicate(...)` `SECURITY DEFINER` functions, which validate the row exists, is `pending_review`, and belongs to a legitimate pair before changing its status | No | Full | None (shared read; controlled write via function) | No | Any authenticated user can read the queue; no authenticated user can set `status` via a raw UPDATE — only via the review function, and only to `confirmed_duplicate`/`rejected` | Low |
+| `career_market_scores` | **Not yet implemented** (Phase 2A.2) — Shared, Ranking 1, not user-specific | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low |
+| `market_skill_mentions` | **Not yet implemented** (Phase 6) — Shared, aggregate market-intelligence snapshot | All authenticated users | No | No | No | Full | None | No | Any authenticated user can read; no authenticated user can write | Low (aggregate/anonymized by construction) |
## User activity domain (user-owned)
| Table | Owner / data type | Browser SELECT | Browser INSERT | Browser UPDATE | Browser DELETE | Service-role | Expected RLS predicate | Anon access | Required isolation test | Sensitivity |
|---|---|---|---|---|---|---|---|---|---|---|
-| `saved_opportunities`, `applications`, `tasks` | One row per user action, owned directly via `user_id` | Own rows only | Own rows only | Own rows only | Own rows only | Full (admin/export/account-deletion) | `user_id = auth.uid()` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; full CRUD on own rows | High (reveals job-search targets, strategy, and status — treat application/task content as sensitive career-strategy data) |
-| `interview_prep_notes` | Owned transitively via `application_id → applications.user_id` (a denormalized `user_id` column on this table is recommended purely to keep the RLS predicate simple and avoid a subquery on every policy check) | Own rows only | Own rows only | Own rows only | Own rows only | Full | `user_id = auth.uid()` (denormalized) or `application_id IN (SELECT id FROM applications WHERE user_id = auth.uid())` if not denormalized | No | Same isolation test pattern as above | High (may contain candid personal notes on interview performance/strategy) |
-| `career_fit_scores`, `development_scores` | User-specific scores against a shared opportunity version | Own rows only | No (written only by the scoring job) | No | No | Full | `user_id = auth.uid()` | No | User A cannot SELECT any score row belonging to user B; user A cannot write any score row via the browser role, including their own | High (`eligibility_status` in particular can reveal immigration/visa-driven exclusion — treat as sensitive personal-attribute data) |
-| `private_opportunities` | **New (Phase 2A, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, owned directly via `user_id`; structurally and permission-wise unrelated to the shared opportunity-identity domain below | Own rows only | Own rows only | Own rows only, content columns only | Own rows only | Full for maintenance/export/account deletion; the browser has **no** grant to write `promoted_to_opportunity_id` under any circumstance — that column is trusted-write only, reserved for a future, not-yet-built promotion mechanism | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK`, following the `work_experience` template exactly | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert denial; ownership-rewrite denial; browser cannot write `promoted_to_opportunity_id`, `created_at`, or `updated_at` | High (reveals job-search targets and interests, same sensitivity class as `saved_opportunities`) |
+| `user_opportunity_state` | Save/hide state for a shared opportunity, one row per `(user_id, opportunity_id)` — **implemented name for what an earlier draft of this document called `saved_opportunities`**, per the naming ADR-018 itself uses | Own rows only | Own rows only | Own rows only, excluding `user_id`/`opportunity_id` | Own rows only | Full (admin/export/account-deletion) | `(select auth.uid()) = user_id` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; full CRUD on own rows; a trigger rejects a `saved_opportunity_version_id` that doesn't belong to the given `opportunity_id` | High (reveals job-search targets and interests) |
+| `applications` | One row per application, owned directly via `user_id`. `shared_opportunity_id` is a stable, server-derived FK to `opportunities` (never browser-writable) that keeps a shared application findable and marked Applied across re-ingestion, independent of the pinned `opportunity_version_id` | Own rows only | Own rows only, excluding `private_opportunity_snapshot`/`status_updated_at`/timestamps (server-derived) | Own rows only, excluding `shared_opportunity_id`, `opportunity_version_id`, `private_opportunity_id`, `private_opportunity_snapshot`, `user_id`, and timestamps | Own rows only | Full (admin/export/account-deletion) | `(select auth.uid()) = user_id` | No | User A cannot SELECT/INSERT/UPDATE/DELETE any row owned by user B; the pinned shared version, the stable `shared_opportunity_id`, and the manual snapshot are all immutable after creation (grant-level, plus a trigger for `private_opportunity_id` re-pointing); exactly-one-source and valid-status are enforced by both a check constraint and a trigger; partial unique indexes on `(user_id, shared_opportunity_id)` and `(user_id, private_opportunity_id)` cap the MVP at one application per user per listing | High (reveals job-search targets, strategy, and status — treat as sensitive career-strategy data) |
+| `interview_prep_notes` | One row per application; `user_id` is denormalized and always server-derived from the referenced application's owner, never client-supplied | Own rows only | Own rows only (browser has no grant to set `user_id` at all) | Own rows only, excluding `user_id`/`application_id`/timestamps | Own rows only | Full | `(select auth.uid()) = user_id` | No | User A cannot SELECT any row belonging to user B; attaching notes to user B's application fails because RLS hides that application from user A's own ownership-lookup subquery | High (may contain candid personal notes on interview performance/strategy) |
+| `private_opportunities` | **Implemented (Phase 2A.1, see [ADR-018](adr/ADR-018-private-manual-opportunities.md))**: manually entered opportunities, owned directly via `user_id`; structurally and permission-wise unrelated to the shared opportunity-identity domain above | Own rows only | Own rows only | Own rows only, content columns including `source_url` (ordinary mutable owner-corrected metadata, not provenance-locked — correcting it never rewrites an already-captured `applications.private_opportunity_snapshot`) | Own rows only | Full for maintenance/export/account deletion; the browser has **no** grant to write `promoted_to_opportunity_id` under any circumstance — that column is trusted-write only, reserved for a future, not-yet-built promotion mechanism | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK`, following the `work_experience` template exactly | No | Owner CRUD, including correcting `source_url`; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert denial; browser cannot write `promoted_to_opportunity_id`, `created_at`, or `updated_at`; deleting a private opportunity referenced by an application sets `applications.private_opportunity_id` to null (`on delete set null`) while the application's own snapshot survives | High (reveals job-search targets and interests) |
+| `career_fit_scores`, `development_scores` | **Not yet implemented** (Phase 3) — user-specific scores against a shared opportunity version | Own rows only | No (written only by the scoring job) | No | No | Full | `(select auth.uid()) = user_id` | No | User A cannot SELECT any score row belonging to user B; user A cannot write any score row via the browser role, including their own | High (`eligibility_status` in particular can reveal immigration/visa-driven exclusion — treat as sensitive personal-attribute data) |
## Storage buckets
diff --git a/ingestion/.env.example b/ingestion/.env.example
new file mode 100644
index 0000000..c53d9ab
--- /dev/null
+++ b/ingestion/.env.example
@@ -0,0 +1,20 @@
+# Copy this file to .env.local (already gitignored) and fill in real local
+# values, then run with:
+#
+# node --env-file=.env.local dist/cli.js ingest --source greenhouse
+#
+# Values come from the *local* Supabase CLI stack only (`supabase status`).
+# This is trusted, service-role code (see docs/adr/ADR-017 and
+# docs/SECURITY_AND_PRIVACY.md #4) -- it bypasses Row Level Security and must
+# never run in the browser or be granted to an untrusted caller. The key is
+# never logged and never printed by this project's code.
+
+# supabase status -> API_URL
+SUPABASE_URL=http://127.0.0.1:54321
+
+# supabase status -> SERVICE_ROLE_KEY (or SECRET_KEY on newer CLI versions)
+SUPABASE_SERVICE_ROLE_KEY=
+
+# Set to 1 only if SUPABASE_URL deliberately points at a real, non-loopback
+# hosted project. Refused otherwise, per ADR-017 (local-first execution).
+# INGESTION_ALLOW_REMOTE=1
diff --git a/ingestion/.gitignore b/ingestion/.gitignore
new file mode 100644
index 0000000..a7415f9
--- /dev/null
+++ b/ingestion/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+*.local
+.env.local
+coverage
diff --git a/ingestion/.prettierignore b/ingestion/.prettierignore
new file mode 100644
index 0000000..ac45658
--- /dev/null
+++ b/ingestion/.prettierignore
@@ -0,0 +1,4 @@
+dist
+node_modules
+coverage
+package-lock.json
diff --git a/ingestion/.prettierrc.json b/ingestion/.prettierrc.json
new file mode 100644
index 0000000..e3b414c
--- /dev/null
+++ b/ingestion/.prettierrc.json
@@ -0,0 +1,5 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/ingestion/README.md b/ingestion/README.md
index 07296a1..4638421 100644
--- a/ingestion/README.md
+++ b/ingestion/README.md
@@ -1,12 +1,43 @@
# ingestion/
-Reserved for the Python source-adapter, normalization, scoring, and resume-parsing pipeline
-described in [../docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) and
-[ADR-003](../docs/adr/ADR-003-python-ingestion-pipeline.md) /
-[ADR-004](../docs/adr/ADR-004-source-adapter-architecture.md).
-
-No code lives here yet — this directory is an intentional placeholder marking a boundary that
-Phase 2+ will fill in, not a forgotten or abandoned folder. Per
-[DEVELOPMENT_ROADMAP.md](../docs/DEVELOPMENT_ROADMAP.md), it stays empty through Phase 0 and
-Phase 1 (foundations and profile domain), since neither needs any ingestion, adapter, or scoring
-code.
+Local TypeScript CLI that imports real opportunity listings from configured, reviewed sources into
+the local (or a real hosted) Supabase project. Trusted, service-role code — see
+[docs/adr/ADR-016](../docs/adr/ADR-016-typescript-ingestion-runtime.md),
+[docs/adr/ADR-017](../docs/adr/ADR-017-local-first-ingestion-execution.md), and
+[docs/INGESTION_ARCHITECTURE.md](../docs/INGESTION_ARCHITECTURE.md) for the full design.
+
+## Setup
+
+```bash
+cd ingestion
+npm ci
+cp .env.example .env.local # fill in SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (supabase status)
+```
+
+`.env.local` is gitignored. The service-role key bypasses Row Level Security — it must never be
+committed, printed, or reach the browser. By default the CLI refuses to run against a
+non-loopback `SUPABASE_URL`; set `INGESTION_ALLOW_REMOTE=1` only for a deliberately configured
+real hosted target.
+
+## Usage
+
+```bash
+npm run ingest -- --source greenhouse # all enabled Greenhouse boards
+npm run ingest -- --source greenhouse --board helsing # one board
+npm run ingest -- --source greenhouse --dry-run # fetch + compare only, zero writes
+```
+
+Only sources already reviewed and seeded (`enabled = true`) in the `sources` table are ever
+fetched — the adapter never enumerates or probes arbitrary board tokens.
+
+## Development
+
+```bash
+npm run typecheck
+npm run lint
+npm run format:check
+npm run test
+```
+
+Tests use synthetic fixtures and a local `node:http` mock server — no live network call. CI runs
+the same checks (`.github/workflows/ci.yml`, `ingestion` job).
diff --git a/ingestion/eslint.config.js b/ingestion/eslint.config.js
new file mode 100644
index 0000000..b78b3dc
--- /dev/null
+++ b/ingestion/eslint.config.js
@@ -0,0 +1,13 @@
+import { globalIgnores } from 'eslint/config'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+import eslintConfigPrettier from 'eslint-config-prettier'
+
+export default tseslint.config(globalIgnores(['dist']), {
+ files: ['**/*.ts'],
+ extends: [tseslint.configs.recommended, eslintConfigPrettier],
+ languageOptions: {
+ ecmaVersion: 2023,
+ globals: { ...globals.node },
+ },
+})
diff --git a/ingestion/package-lock.json b/ingestion/package-lock.json
new file mode 100644
index 0000000..c985ff4
--- /dev/null
+++ b/ingestion/package-lock.json
@@ -0,0 +1,2777 @@
+{
+ "name": "careeros-ingestion",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "careeros-ingestion",
+ "version": "0.0.0",
+ "dependencies": {
+ "@supabase/supabase-js": "^2.110.8",
+ "entities": "^7.0.1",
+ "sanitize-html": "^2.17.6"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.2",
+ "@types/sanitize-html": "^2.16.1",
+ "eslint": "^10.8.0",
+ "eslint-config-prettier": "^10.1.8",
+ "globals": "^17.7.0",
+ "prettier": "^3.9.6",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.65.0",
+ "vitest": "^4.1.10"
+ },
+ "engines": {
+ "node": "22.23.1"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz",
+ "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz",
+ "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz",
+ "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz",
+ "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz",
+ "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz",
+ "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz",
+ "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz",
+ "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz",
+ "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz",
+ "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz",
+ "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz",
+ "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz",
+ "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz",
+ "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@supabase/auth-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.0.tgz",
+ "integrity": "sha512-8qAdObNQHKbSeVBLmf2WLNT7+bCE8zBofpCFiNUqGBAl5qw9VagSvcmPXlh78McAU7iHrGik1oeJxiB2A6B29w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.0.tgz",
+ "integrity": "sha512-2DdaEZs0vq86orMIZBO+eM5w5/UxZb1EZyg2JraBKS4W9BzfFO+fOFD6YStmZ7iAOWvxNTGPjPNItsofpGgTCA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
+ "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.0.tgz",
+ "integrity": "sha512-4HKCVq32Jlk/wS8Ud8QgaAuQ4u6w1hZfw/gS5IcIN7wYddElMj4kfiCZpHMPfncomMnYzAKBUhwBZPpRcTH2Yw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.0.tgz",
+ "integrity": "sha512-McFFP+ivFDMTaCEh8JDpG+sPEmv5IjKvrP0uTH3Lbsriai4KbxB8ycY8TqPQnbjhOVjEifQm1q0y2tL1CUw9Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "0.4.5",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.0.tgz",
+ "integrity": "sha512-X44Bl045X/e5e2tJqWsY+JmQvgtm04BJuijiWprIWYLn0mDvGnu8hLdzPOPcTVji7wlqbt2ZUHttsv+rGKQYXw==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.112.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.0.tgz",
+ "integrity": "sha512-dHVOgog58GOagtrZuPxJYg/R45ZV2U0qqgXffH+lMlt1OS+267Pw4g7bw3iXCGxN85OufiE0nI1baxDXlgEyfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.112.0",
+ "@supabase/functions-js": "2.112.0",
+ "@supabase/postgrest-js": "2.112.0",
+ "@supabase/realtime-js": "2.112.0",
+ "@supabase/storage-js": "2.112.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/sanitize-html": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
+ "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "htmlparser2": "^10.1"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
+ "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/type-utils": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.66.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz",
+ "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz",
+ "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.66.0",
+ "@typescript-eslint/types": "^8.66.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz",
+ "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz",
+ "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz",
+ "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz",
+ "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz",
+ "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.66.0",
+ "@typescript-eslint/tsconfig-utils": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/visitor-keys": "8.66.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz",
+ "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.66.0",
+ "@typescript-eslint/types": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz",
+ "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.66.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/dayjs": {
+ "version": "1.11.21",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+ "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.8.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz",
+ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.2",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.9.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
+ "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/launder": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
+ "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
+ "license": "MIT",
+ "dependencies": {
+ "dayjs": "^1.11.7"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
+ "license": "MIT"
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz",
+ "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.2",
+ "@rolldown/binding-darwin-arm64": "1.2.2",
+ "@rolldown/binding-darwin-x64": "1.2.2",
+ "@rolldown/binding-freebsd-x64": "1.2.2",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.2",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.2",
+ "@rolldown/binding-linux-arm64-musl": "1.2.2",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.2",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-gnu": "1.2.2",
+ "@rolldown/binding-linux-x64-musl": "1.2.2",
+ "@rolldown/binding-openharmony-arm64": "1.2.2",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.2",
+ "@rolldown/binding-win32-x64-msvc": "1.2.2"
+ }
+ },
+ "node_modules/sanitize-html": {
+ "version": "2.17.6",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz",
+ "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==",
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^12.0.0",
+ "is-plain-object": "^5.0.0",
+ "launder": "^1.7.1",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/sanitize-html/node_modules/htmlparser2": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
+ "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "domutils": "^4.0.2",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
+ "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.66.0",
+ "@typescript-eslint/parser": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/ingestion/package.json b/ingestion/package.json
new file mode 100644
index 0000000..f30abec
--- /dev/null
+++ b/ingestion/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "careeros-ingestion",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "engines": {
+ "node": "22.23.1"
+ },
+ "scripts": {
+ "build": "tsc",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint .",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "ingest": "npm run build --silent && node --env-file=.env.local dist/cli.js ingest"
+ },
+ "dependencies": {
+ "@supabase/supabase-js": "^2.110.8",
+ "entities": "^7.0.1",
+ "sanitize-html": "^2.17.6"
+ },
+ "devDependencies": {
+ "@types/node": "^24.13.2",
+ "@types/sanitize-html": "^2.16.1",
+ "eslint": "^10.8.0",
+ "eslint-config-prettier": "^10.1.8",
+ "globals": "^17.7.0",
+ "prettier": "^3.9.6",
+ "typescript": "~6.0.2",
+ "typescript-eslint": "^8.65.0",
+ "vitest": "^4.1.10"
+ }
+}
diff --git a/ingestion/src/adapters/greenhouse.ts b/ingestion/src/adapters/greenhouse.ts
new file mode 100644
index 0000000..0b399fa
--- /dev/null
+++ b/ingestion/src/adapters/greenhouse.ts
@@ -0,0 +1,252 @@
+import { fetchWithRetry } from '../httpRetry.js'
+import { normalizeUrl } from '../identity.js'
+import { sanitizeDescription } from '../sanitize.js'
+import type { NormalizedListing, RemoteMode, SourceRow } from './types.js'
+
+export const GREENHOUSE_ADAPTER_KIND = 'greenhouse'
+export const GREENHOUSE_ADAPTER_VERSION = 'greenhouse/1.0.0'
+
+// Per docs/DATA_SOURCES_AND_COMPLIANCE.md #3 (Greenhouse Job Board API):
+// unauthenticated, robots.txt-permitted GET, no documented rate limit --
+// this is a conservative self-imposed limit regardless.
+export const GREENHOUSE_POLITENESS = {
+ minIntervalMs: 1000,
+ maxRequestsPerRun: 1,
+ userAgent:
+ 'CareerOS-Ingestion/1.0 (+https://github.com/abdo2006-dev/CareerOS; personal local ingestion, contact via repository issues)',
+ timeoutMs: 15_000,
+ maxResponseBytes: 8 * 1024 * 1024,
+}
+
+interface GreenhouseJobRaw {
+ id?: unknown
+ title?: unknown
+ absolute_url?: unknown
+ location?: unknown
+ updated_at?: unknown
+ first_published?: unknown
+ application_deadline?: unknown
+ company_name?: unknown
+ content?: unknown
+ departments?: unknown
+ offices?: unknown
+}
+
+export interface GreenhouseJob {
+ id: number
+ title: string
+ absoluteUrl: string
+ locationName: string | null
+ updatedAt: string | null
+ firstPublished: string | null
+ applicationDeadline: string | null
+ companyName: string | null
+ content: string
+ departments: string[]
+ offices: string[]
+}
+
+function namesOf(value: unknown): string[] {
+ if (!Array.isArray(value)) return []
+ return value
+ .filter(
+ (entry): entry is { name: string } =>
+ typeof entry === 'object' &&
+ entry !== null &&
+ typeof (entry as { name?: unknown }).name === 'string',
+ )
+ .map((entry) => entry.name)
+ .slice(0, 10)
+}
+
+/**
+ * Hand-written type guard per docs/INGESTION_ARCHITECTURE.md #3 -- a single
+ * adapter with a modest field count does not justify a zod dependency.
+ * Throws on shape mismatch; the orchestrator catches this per-record so one
+ * malformed job never aborts the whole run.
+ */
+export function parseGreenhouseJob(raw: unknown): GreenhouseJob {
+ if (typeof raw !== 'object' || raw === null) {
+ throw new Error('job record is not an object')
+ }
+ const job = raw as GreenhouseJobRaw
+ if (typeof job.id !== 'number') throw new Error('job.id must be a number')
+ if (typeof job.title !== 'string' || !job.title.trim()) {
+ throw new Error('job.title must be a non-empty string')
+ }
+ if (typeof job.absolute_url !== 'string' || !job.absolute_url.trim()) {
+ throw new Error('job.absolute_url must be a non-empty string')
+ }
+ if (typeof job.content !== 'string') {
+ throw new Error('job.content must be a string')
+ }
+
+ const location = job.location
+ const locationName =
+ typeof location === 'object' &&
+ location !== null &&
+ typeof (location as { name?: unknown }).name === 'string'
+ ? ((location as { name: string }).name as string)
+ : null
+
+ return {
+ id: job.id,
+ title: job.title,
+ absoluteUrl: job.absolute_url,
+ locationName,
+ updatedAt: typeof job.updated_at === 'string' ? job.updated_at : null,
+ firstPublished:
+ typeof job.first_published === 'string' ? job.first_published : null,
+ applicationDeadline:
+ typeof job.application_deadline === 'string'
+ ? job.application_deadline
+ : null,
+ companyName: typeof job.company_name === 'string' ? job.company_name : null,
+ content: job.content,
+ departments: namesOf(job.departments),
+ offices: namesOf(job.offices),
+ }
+}
+
+const WORKING_STUDENT_HINTS = ['working student', 'werkstudent']
+const INTERNSHIP_HINTS = ['intern', 'internship', 'praktikum', 'praktikant']
+const PHD_HINTS = ['phd', 'doctoral', 'doktorand']
+
+/**
+ * Best-effort classification from the job title only -- Greenhouse does not
+ * provide a structured kind/employment-type field. Deliberately
+ * conservative: anything that doesn't match a clear hint falls back to
+ * "other"/"full_time" rather than guessing.
+ */
+export function classifyOpportunityKind(title: string): string {
+ const lower = title.toLowerCase()
+ if (WORKING_STUDENT_HINTS.some((hint) => lower.includes(hint))) {
+ return 'working_student'
+ }
+ if (INTERNSHIP_HINTS.some((hint) => lower.includes(hint))) return 'internship'
+ if (PHD_HINTS.some((hint) => lower.includes(hint))) return 'phd'
+ return 'other'
+}
+
+export function classifyEmploymentType(
+ opportunityKind: string,
+ title: string,
+): string {
+ if (
+ opportunityKind === 'internship' ||
+ opportunityKind === 'working_student'
+ ) {
+ return 'internship'
+ }
+ const lower = title.toLowerCase()
+ if (lower.includes('part-time') || lower.includes('part time')) {
+ return 'part_time'
+ }
+ return 'full_time'
+}
+
+export function classifyRemoteMode(locationName: string | null): RemoteMode {
+ if (!locationName) return 'unknown'
+ const lower = locationName.toLowerCase()
+ if (lower.includes('remote')) return 'remote'
+ if (lower.includes('hybrid')) return 'hybrid'
+ return 'onsite'
+}
+
+export function normalizeGreenhouseJob(
+ job: GreenhouseJob,
+ source: Pick,
+): NormalizedListing {
+ const opportunityKind = classifyOpportunityKind(job.title)
+ const canonicalUrl = normalizeUrl(job.absoluteUrl)
+ return {
+ externalId: String(job.id),
+ canonicalUrl,
+ applicationUrl: canonicalUrl,
+ title: job.title.trim(),
+ organization: job.companyName?.trim() || source.display_name,
+ description: sanitizeDescription(job.content),
+ locationText: job.locationName,
+ country: null,
+ region: null,
+ city: null,
+ remoteMode: classifyRemoteMode(job.locationName),
+ opportunityKind,
+ employmentType: classifyEmploymentType(opportunityKind, job.title),
+ postedAt: job.firstPublished,
+ applicationDeadline: job.applicationDeadline,
+ sourceUpdatedAt: job.updatedAt,
+ sourceMetadata: { departments: job.departments, offices: job.offices },
+ }
+}
+
+export interface GreenhouseFetchResult {
+ records: unknown[]
+ pageComplete: boolean
+}
+
+/**
+ * A single request returns the board's complete job list -- no pagination
+ * needed (docs/DATA_SOURCES_AND_COMPLIANCE.md #3). `pageComplete` is only
+ * true when the response is a well-formed `{ jobs: [...] }` document; any
+ * other shape, or a non-2xx response, is reported incomplete so the
+ * orchestrator never treats it as license to close anything.
+ */
+export async function fetchGreenhouseBoard(
+ baseUrl: string,
+): Promise {
+ const url = `${baseUrl}?content=true`
+ const controller = new AbortController()
+ const timeout = setTimeout(
+ () => controller.abort(),
+ GREENHOUSE_POLITENESS.timeoutMs,
+ )
+ try {
+ const response = await fetchWithRetry(url, {
+ headers: {
+ 'User-Agent': GREENHOUSE_POLITENESS.userAgent,
+ Accept: 'application/json',
+ },
+ signal: controller.signal,
+ })
+ if (!response.ok) {
+ return { records: [], pageComplete: false }
+ }
+
+ const contentLength = response.headers.get('content-length')
+ if (
+ contentLength &&
+ Number(contentLength) > GREENHOUSE_POLITENESS.maxResponseBytes
+ ) {
+ throw new Error(
+ `response exceeds maxResponseBytes (Content-Length: ${contentLength})`,
+ )
+ }
+
+ const text = await response.text()
+ if (
+ Buffer.byteLength(text, 'utf8') > GREENHOUSE_POLITENESS.maxResponseBytes
+ ) {
+ throw new Error('response exceeds maxResponseBytes')
+ }
+
+ let body: unknown
+ try {
+ body = JSON.parse(text)
+ } catch {
+ return { records: [], pageComplete: false }
+ }
+
+ if (
+ typeof body !== 'object' ||
+ body === null ||
+ !Array.isArray((body as { jobs?: unknown }).jobs)
+ ) {
+ return { records: [], pageComplete: false }
+ }
+
+ return { records: (body as { jobs: unknown[] }).jobs, pageComplete: true }
+ } finally {
+ clearTimeout(timeout)
+ }
+}
diff --git a/ingestion/src/adapters/types.ts b/ingestion/src/adapters/types.ts
new file mode 100644
index 0000000..15f89ff
--- /dev/null
+++ b/ingestion/src/adapters/types.ts
@@ -0,0 +1,35 @@
+export type RemoteMode = 'onsite' | 'hybrid' | 'remote' | 'unknown'
+
+/**
+ * The common normalized shape every adapter must produce, matching the
+ * columns apply_source_listing accepts. See docs/DATA_MODEL.md
+ * "opportunity_versions -- implemented field list".
+ */
+export interface NormalizedListing {
+ externalId: string
+ canonicalUrl: string
+ applicationUrl: string | null
+ title: string
+ organization: string
+ description: string
+ locationText: string | null
+ country: string | null
+ region: string | null
+ city: string | null
+ remoteMode: RemoteMode
+ opportunityKind: string
+ employmentType: string
+ postedAt: string | null
+ applicationDeadline: string | null
+ sourceUpdatedAt: string | null
+ sourceMetadata: Record
+}
+
+export interface SourceRow {
+ id: string
+ source_key: string
+ adapter_kind: string
+ display_name: string
+ base_url: string
+ enabled: boolean
+}
diff --git a/ingestion/src/cli.ts b/ingestion/src/cli.ts
new file mode 100644
index 0000000..0838f47
--- /dev/null
+++ b/ingestion/src/cli.ts
@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+import { loadEnv } from './env.js'
+import { createServiceClient } from './db.js'
+import {
+ fetchGreenhouseBoard,
+ parseGreenhouseJob,
+ normalizeGreenhouseJob,
+ GREENHOUSE_ADAPTER_KIND,
+} from './adapters/greenhouse.js'
+import { ingestDryRun, ingestReal } from './orchestrator.js'
+import type { SourceRow } from './adapters/types.js'
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+interface CliArgs {
+ source: string
+ board?: string
+ dryRun: boolean
+}
+
+function parseArgs(argv: string[]): {
+ command: string | undefined
+ args: CliArgs
+} {
+ const [command, ...rest] = argv
+ const args: CliArgs = { source: '', dryRun: false }
+ for (let index = 0; index < rest.length; index += 1) {
+ const token = rest[index]
+ if (token === '--source') {
+ args.source = rest[(index += 1)]
+ } else if (token === '--board') {
+ args.board = rest[(index += 1)]
+ } else if (token === '--dry-run') {
+ args.dryRun = true
+ } else {
+ throw new Error(`Unknown argument: ${token}`)
+ }
+ }
+ return { command, args }
+}
+
+async function loadSources(
+ client: SupabaseClient,
+ adapterKind: string,
+ board: string | undefined,
+): Promise {
+ let query = client
+ .from('sources')
+ .select('id, source_key, adapter_kind, display_name, base_url, enabled')
+ .eq('adapter_kind', adapterKind)
+ .eq('enabled', true)
+ if (board) query = query.eq('source_key', `${adapterKind}:${board}`)
+
+ const { data, error } = await query
+ if (error) throw error
+ if (!data || data.length === 0) {
+ throw new Error(
+ board
+ ? `No enabled source is configured for "${adapterKind}:${board}".`
+ : `No enabled sources are configured for adapter "${adapterKind}".`,
+ )
+ }
+ return data as SourceRow[]
+}
+
+async function runIngest(args: CliArgs): Promise {
+ if (args.source !== GREENHOUSE_ADAPTER_KIND) {
+ throw new Error(
+ `Unsupported --source "${args.source}". Only "greenhouse" is implemented.`,
+ )
+ }
+
+ const env = loadEnv()
+ const client = createServiceClient(env)
+ const sources = await loadSources(client, args.source, args.board)
+
+ for (const source of sources) {
+ console.log(`\n[${source.source_key}] fetching...`)
+ const fetchResult = await fetchGreenhouseBoard(source.base_url)
+ console.log(
+ `[${source.source_key}] fetched ${fetchResult.records.length} record(s); ` +
+ `complete response: ${fetchResult.pageComplete}`,
+ )
+
+ if (args.dryRun) {
+ const summary = await ingestDryRun({
+ client,
+ source,
+ records: fetchResult.records,
+ pageComplete: fetchResult.pageComplete,
+ parse: parseGreenhouseJob,
+ normalize: (raw, s) =>
+ normalizeGreenhouseJob(
+ raw as ReturnType,
+ s,
+ ),
+ })
+ console.log(
+ `[${source.source_key}] DRY RUN -- no writes were made. Would create ` +
+ `${summary.wouldCreate}, update ${summary.wouldUpdate}, leave ` +
+ `${summary.wouldBeUnchanged} unchanged, and (if this were a complete ` +
+ `run) close up to ${summary.wouldClose}. ${summary.errorCount} record ` +
+ `error(s).`,
+ )
+ if (summary.errors.length > 0) {
+ console.log(`[${source.source_key}] errors:`, summary.errors)
+ }
+ continue
+ }
+
+ const summary = await ingestReal({
+ client,
+ source,
+ records: fetchResult.records,
+ pageComplete: fetchResult.pageComplete,
+ parse: parseGreenhouseJob,
+ normalize: (raw, s) =>
+ normalizeGreenhouseJob(raw as ReturnType, s),
+ })
+ console.log(
+ `[${source.source_key}] run ${summary.runId} (${summary.status}) -- ` +
+ `${summary.recordsNew} new, ${summary.recordsUpdated} updated, ` +
+ `${summary.recordsUnchanged} unchanged, ${summary.errorCount} error(s).`,
+ )
+ if (summary.errors.length > 0) {
+ console.log(`[${source.source_key}] errors:`, summary.errors)
+ }
+ }
+}
+
+async function main(): Promise {
+ const { command, args } = parseArgs(process.argv.slice(2))
+ if (command !== 'ingest') {
+ throw new Error(
+ `Unknown command "${command ?? ''}". Usage: ingest --source greenhouse [--board ] [--dry-run]`,
+ )
+ }
+ await runIngest(args)
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : String(error))
+ process.exitCode = 1
+})
diff --git a/ingestion/src/db.ts b/ingestion/src/db.ts
new file mode 100644
index 0000000..ae5015d
--- /dev/null
+++ b/ingestion/src/db.ts
@@ -0,0 +1,8 @@
+import { createClient, type SupabaseClient } from '@supabase/supabase-js'
+import type { IngestionEnv } from './env.js'
+
+export function createServiceClient(env: IngestionEnv): SupabaseClient {
+ return createClient(env.supabaseUrl, env.serviceRoleKey, {
+ auth: { persistSession: false, autoRefreshToken: false },
+ })
+}
diff --git a/ingestion/src/env.ts b/ingestion/src/env.ts
new file mode 100644
index 0000000..7a9b28c
--- /dev/null
+++ b/ingestion/src/env.ts
@@ -0,0 +1,45 @@
+export interface IngestionEnv {
+ supabaseUrl: string
+ serviceRoleKey: string
+}
+
+const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1'])
+
+/**
+ * Reads the trusted service-role credential from the process environment
+ * only -- never from a committed file, and never printed or logged. Run
+ * with `node --env-file=.env.local` (Node 22) to populate it locally.
+ */
+export function loadEnv(): IngestionEnv {
+ const supabaseUrl = process.env.SUPABASE_URL
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
+
+ if (!supabaseUrl || !serviceRoleKey) {
+ throw new Error(
+ 'Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY. Copy .env.example to ' +
+ '.env.local, fill in the local Supabase values (see `supabase status`), and ' +
+ 'run with `node --env-file=.env.local dist/cli.js ingest ...`.',
+ )
+ }
+
+ let hostname: string
+ try {
+ hostname = new URL(supabaseUrl).hostname
+ } catch {
+ throw new Error('SUPABASE_URL is not a valid URL.')
+ }
+
+ if (
+ !LOOPBACK_HOSTNAMES.has(hostname) &&
+ process.env.INGESTION_ALLOW_REMOTE !== '1'
+ ) {
+ throw new Error(
+ `Refusing to run against a non-loopback SUPABASE_URL (host: ${hostname}). ` +
+ 'This CLI holds a trusted, RLS-bypassing service-role credential (see ' +
+ 'docs/adr/ADR-017-local-first-ingestion-execution.md); set INGESTION_ALLOW_REMOTE=1 ' +
+ 'only if you have deliberately configured a real hosted target.',
+ )
+ }
+
+ return { supabaseUrl, serviceRoleKey }
+}
diff --git a/ingestion/src/httpRetry.ts b/ingestion/src/httpRetry.ts
new file mode 100644
index 0000000..0b1be9c
--- /dev/null
+++ b/ingestion/src/httpRetry.ts
@@ -0,0 +1,45 @@
+export interface RetryOptions {
+ maxAttempts: number
+ baseDelayMs: number
+}
+
+const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxAttempts: 3, baseDelayMs: 500 }
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/**
+ * Retries only on 429/5xx responses or network errors, per
+ * docs/INGESTION_ARCHITECTURE.md #4 -- never on a 4xx response, since that
+ * indicates a request the server will never accept regardless of retrying.
+ * Honors a numeric `Retry-After` header (seconds) when present.
+ */
+export async function fetchWithRetry(
+ url: string,
+ init: RequestInit,
+ options: RetryOptions = DEFAULT_RETRY_OPTIONS,
+): Promise {
+ let lastError: unknown
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
+ try {
+ const response = await fetch(url, init)
+ const shouldRetry = response.status === 429 || response.status >= 500
+ if (!shouldRetry || attempt === options.maxAttempts) return response
+
+ const retryAfterHeader = response.headers.get('retry-after')
+ const retryAfterSeconds = retryAfterHeader
+ ? Number(retryAfterHeader)
+ : NaN
+ const delayMs = Number.isFinite(retryAfterSeconds)
+ ? retryAfterSeconds * 1000
+ : options.baseDelayMs * 2 ** (attempt - 1)
+ await sleep(delayMs)
+ } catch (error) {
+ lastError = error
+ if (attempt === options.maxAttempts) throw error
+ await sleep(options.baseDelayMs * 2 ** (attempt - 1))
+ }
+ }
+ throw lastError
+}
diff --git a/ingestion/src/identity.ts b/ingestion/src/identity.ts
new file mode 100644
index 0000000..4c29d13
--- /dev/null
+++ b/ingestion/src/identity.ts
@@ -0,0 +1,60 @@
+import { createHash } from 'node:crypto'
+import type { NormalizedListing } from './adapters/types.js'
+
+/**
+ * Conservative URL normalization per docs/INGESTION_ARCHITECTURE.md #5:
+ * lowercase scheme+host, strip default port/fragment/userinfo, collapse
+ * duplicate slashes, strip one trailing slash. No tracking-parameter
+ * stripping here -- that is per-adapter (a significance allowlist), and the
+ * Greenhouse adapter does not add one because its URLs carry no query
+ * parameters that need it.
+ */
+export function normalizeUrl(rawUrl: string): string {
+ const url = new URL(rawUrl)
+ url.protocol = url.protocol.toLowerCase()
+ url.hostname = url.hostname.toLowerCase()
+ url.hash = ''
+ url.username = ''
+ url.password = ''
+ if (
+ (url.protocol === 'https:' && url.port === '443') ||
+ (url.protocol === 'http:' && url.port === '80')
+ ) {
+ url.port = ''
+ }
+ let pathname = url.pathname.replace(/\/{2,}/g, '/')
+ if (pathname.length > 1 && pathname.endsWith('/')) {
+ pathname = pathname.slice(0, -1)
+ }
+ url.pathname = pathname
+ return url.toString()
+}
+
+// ASCII unit separator (code point 31) -- not a character any of the hashed
+// text fields can plausibly contain, so a value shifting between adjacent
+// fields can never coincidentally produce the same hash as a genuinely
+// different posting.
+const HASH_FIELD_SEPARATOR = String.fromCharCode(31)
+
+/**
+ * A deterministic hash over exactly the historically-meaningful fields that
+ * define a version (ADR-006) -- not source_metadata, which is supplementary
+ * debug context and must not by itself trigger a new version.
+ */
+export function computeContentHash(listing: NormalizedListing): string {
+ const parts = [
+ listing.title,
+ listing.organization,
+ listing.description,
+ listing.locationText ?? '',
+ listing.remoteMode,
+ listing.opportunityKind,
+ listing.employmentType,
+ listing.applicationUrl ?? '',
+ listing.applicationDeadline ?? '',
+ listing.postedAt ?? '',
+ ]
+ return createHash('sha256')
+ .update(parts.join(HASH_FIELD_SEPARATOR))
+ .digest('hex')
+}
diff --git a/ingestion/src/orchestrator.ts b/ingestion/src/orchestrator.ts
new file mode 100644
index 0000000..9072765
--- /dev/null
+++ b/ingestion/src/orchestrator.ts
@@ -0,0 +1,262 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { computeContentHash } from './identity.js'
+import type { NormalizedListing, SourceRow } from './adapters/types.js'
+
+const MAX_ERROR_SUMMARY_ENTRIES = 32
+const MAX_ERROR_SUMMARY_BYTES = 8192
+
+export interface ErrorEntry {
+ type: string
+ message: string
+}
+
+// Postgrest/Supabase errors are plain objects with a string `.message`, not
+// `instanceof Error` -- extract that first so a failed RPC call produces a
+// real, readable message instead of "[object Object]".
+export function describeError(error: unknown): string {
+ if (error instanceof Error) return error.message
+ if (
+ typeof error === 'object' &&
+ error !== null &&
+ 'message' in error &&
+ typeof (error as { message: unknown }).message === 'string'
+ ) {
+ return (error as { message: string }).message
+ }
+ return String(error)
+}
+
+function pushError(errors: ErrorEntry[], type: string, message: string): void {
+ if (errors.length < MAX_ERROR_SUMMARY_ENTRIES) {
+ // Never description text or raw payloads (SECURITY_AND_PRIVACY.md #9) --
+ // only the error type/message, capped in length.
+ errors.push({ type, message: message.slice(0, 300) })
+ }
+}
+
+function capErrorSummary(errors: ErrorEntry[]): ErrorEntry[] {
+ let capped = errors
+ while (
+ capped.length > 0 &&
+ Buffer.byteLength(JSON.stringify(capped), 'utf8') > MAX_ERROR_SUMMARY_BYTES
+ ) {
+ capped = capped.slice(0, -1)
+ }
+ return capped
+}
+
+export interface IngestPipelineOptions {
+ client: SupabaseClient
+ source: SourceRow
+ records: unknown[]
+ pageComplete: boolean
+ parse: (raw: unknown) => unknown
+ normalize: (raw: unknown, source: SourceRow) => NormalizedListing
+}
+
+export interface DryRunSummary {
+ recordsFound: number
+ wouldCreate: number
+ wouldUpdate: number
+ wouldBeUnchanged: number
+ wouldClose: number
+ errorCount: number
+ errors: ErrorEntry[]
+ pageComplete: boolean
+}
+
+interface ExistingListingRow {
+ id: string
+ opportunity_id: string
+ opportunities: { current_version_id: string | null } | null
+}
+
+/**
+ * Dry run performs *zero writes*, including to ingestion_runs -- it never
+ * calls begin_ingestion_run/apply_source_listing/finalize_ingestion_run.
+ * Every step (fetch already happened by the time this runs; here: parse,
+ * normalize, sanitize, hash, and compare against current database state via
+ * plain SELECTs) runs for real, but nothing is persisted.
+ */
+export async function ingestDryRun(
+ options: IngestPipelineOptions,
+): Promise {
+ const errors: ErrorEntry[] = []
+ let wouldCreate = 0
+ let wouldUpdate = 0
+ let wouldBeUnchanged = 0
+ const seenExternalIds = new Set()
+
+ for (const raw of options.records) {
+ try {
+ const parsed = options.parse(raw)
+ const normalized = options.normalize(parsed, options.source)
+ seenExternalIds.add(normalized.externalId)
+ const contentHash = computeContentHash(normalized)
+
+ const { data: existing, error: listingError } = await options.client
+ .from('source_listings')
+ .select('id, opportunity_id, opportunities(current_version_id)')
+ .eq('source_id', options.source.id)
+ .eq('external_id', normalized.externalId)
+ .maybeSingle()
+ if (listingError) throw listingError
+
+ if (!existing) {
+ wouldCreate += 1
+ continue
+ }
+
+ const currentVersionId =
+ existing.opportunities?.current_version_id ?? null
+ let currentHash: string | null = null
+ if (currentVersionId) {
+ const { data: versionRow, error: versionError } = await options.client
+ .from('opportunity_versions')
+ .select('content_hash')
+ .eq('id', currentVersionId)
+ .maybeSingle<{ content_hash: string }>()
+ if (versionError) throw versionError
+ currentHash = versionRow?.content_hash ?? null
+ }
+
+ if (currentHash !== contentHash) wouldUpdate += 1
+ else wouldBeUnchanged += 1
+ } catch (error) {
+ pushError(errors, 'record_error', describeError(error))
+ }
+ }
+
+ let wouldClose = 0
+ if (options.pageComplete) {
+ const { data: activeListings, error: activeError } = await options.client
+ .from('source_listings')
+ .select('external_id, absence_count')
+ .eq('source_id', options.source.id)
+ .eq('status', 'active')
+ if (!activeError && activeListings) {
+ wouldClose = activeListings.filter(
+ (row: { external_id: string; absence_count: number }) =>
+ !seenExternalIds.has(row.external_id) && row.absence_count + 1 >= 2,
+ ).length
+ }
+ }
+
+ return {
+ recordsFound: options.records.length,
+ wouldCreate,
+ wouldUpdate,
+ wouldBeUnchanged,
+ wouldClose,
+ errorCount: errors.length,
+ errors: capErrorSummary(errors),
+ pageComplete: options.pageComplete,
+ }
+}
+
+export interface RealRunSummary {
+ runId: string
+ status: 'complete' | 'partial'
+ recordsFound: number
+ recordsNew: number
+ recordsUpdated: number
+ recordsUnchanged: number
+ errorCount: number
+ errors: ErrorEntry[]
+}
+
+interface ApplyListingRpcRow {
+ outcome: 'created' | 'updated' | 'unchanged'
+ opportunity_id: string
+ source_listing_id: string
+ opportunity_version_id: string
+}
+
+/**
+ * The real, writing path: begin_ingestion_run, then apply_source_listing
+ * once per record (each call atomic per docs/adr and this migration's
+ * design), then finalize_ingestion_run -- which performs the conservative
+ * closure step, and only when this run's pageComplete is true.
+ */
+export async function ingestReal(
+ options: IngestPipelineOptions,
+): Promise {
+ const errors: ErrorEntry[] = []
+ let recordsNew = 0
+ let recordsUpdated = 0
+ let recordsUnchanged = 0
+
+ const { data: runId, error: beginError } = await options.client.rpc(
+ 'begin_ingestion_run',
+ { p_source_key: options.source.source_key, p_dry_run: false },
+ )
+ if (beginError) throw beginError
+
+ for (const raw of options.records) {
+ try {
+ const parsed = options.parse(raw)
+ const normalized = options.normalize(parsed, options.source)
+ const contentHash = computeContentHash(normalized)
+
+ const { data, error } = await options.client.rpc('apply_source_listing', {
+ p_source_key: options.source.source_key,
+ p_external_id: normalized.externalId,
+ p_canonical_source_url: normalized.canonicalUrl,
+ p_application_url: normalized.applicationUrl,
+ p_title: normalized.title,
+ p_organization: normalized.organization,
+ p_description: normalized.description,
+ p_location_text: normalized.locationText,
+ p_country: normalized.country,
+ p_region: normalized.region,
+ p_city: normalized.city,
+ p_remote_mode: normalized.remoteMode,
+ p_opportunity_kind: normalized.opportunityKind,
+ p_employment_type: normalized.employmentType,
+ p_posted_at: normalized.postedAt,
+ p_application_deadline: normalized.applicationDeadline,
+ p_source_updated_at: normalized.sourceUpdatedAt,
+ p_content_hash: contentHash,
+ p_source_metadata: normalized.sourceMetadata,
+ })
+ if (error) throw error
+
+ const outcome = (data as ApplyListingRpcRow[] | null)?.[0]?.outcome
+ if (outcome === 'created') recordsNew += 1
+ else if (outcome === 'updated') recordsUpdated += 1
+ else recordsUnchanged += 1
+ } catch (error) {
+ pushError(errors, 'record_error', describeError(error))
+ }
+ }
+
+ const status: 'complete' | 'partial' = options.pageComplete
+ ? 'complete'
+ : 'partial'
+ const cappedErrors = capErrorSummary(errors)
+
+ const { error: finalizeError } = await options.client.rpc(
+ 'finalize_ingestion_run',
+ {
+ p_run_id: runId,
+ p_status: status,
+ p_records_found: options.records.length,
+ p_records_new: recordsNew,
+ p_records_updated: recordsUpdated,
+ p_error_count: errors.length,
+ p_error_summary: cappedErrors,
+ },
+ )
+ if (finalizeError) throw finalizeError
+
+ return {
+ runId: runId as string,
+ status,
+ recordsFound: options.records.length,
+ recordsNew,
+ recordsUpdated,
+ recordsUnchanged,
+ errorCount: errors.length,
+ errors: cappedErrors,
+ }
+}
diff --git a/ingestion/src/sanitize.ts b/ingestion/src/sanitize.ts
new file mode 100644
index 0000000..1953b2b
--- /dev/null
+++ b/ingestion/src/sanitize.ts
@@ -0,0 +1,72 @@
+import sanitizeHtml from 'sanitize-html'
+import { decodeHTML } from 'entities'
+
+// Allowlist per docs/INGESTION_ARCHITECTURE.md #7. Everything else --
+// and welcome.
bad link apply ",
+ "departments": [],
+ "offices": []
+ }
+ ]
+}
diff --git a/ingestion/test/fixtures/greenhouse-board-valid.json b/ingestion/test/fixtures/greenhouse-board-valid.json
new file mode 100644
index 0000000..48ab703
--- /dev/null
+++ b/ingestion/test/fixtures/greenhouse-board-valid.json
@@ -0,0 +1,30 @@
+{
+ "jobs": [
+ {
+ "id": 1000001,
+ "title": "Working Student, Flight Software",
+ "absolute_url": "https://job-boards.greenhouse.io/synthetic-example/jobs/1000001",
+ "location": { "name": "Munich, Germany" },
+ "updated_at": "2026-07-01T09:00:00Z",
+ "first_published": "2026-06-15T09:00:00Z",
+ "application_deadline": "2026-09-01T00:00:00Z",
+ "company_name": "Synthetic Example GmbH",
+ "content": "Support our autonomy team with flight software.
Write C++ Review pull requests ",
+ "departments": [{ "id": 1, "name": "Engineering" }],
+ "offices": [{ "id": 1, "name": "Munich" }]
+ },
+ {
+ "id": 1000002,
+ "title": "PhD Researcher, Applied Machine Learning",
+ "absolute_url": "https://job-boards.greenhouse.io/synthetic-example/jobs/1000002",
+ "location": { "name": "Remote - Germany" },
+ "updated_at": "2026-07-02T09:00:00Z",
+ "first_published": "2026-06-20T09:00:00Z",
+ "application_deadline": null,
+ "company_name": "Synthetic Example GmbH",
+ "content": "Research applied ML for sensor fusion.
",
+ "departments": [{ "id": 2, "name": "Research" }],
+ "offices": []
+ }
+ ]
+}
diff --git a/ingestion/test/fixtures/greenhouse-board-with-malformed-record.json b/ingestion/test/fixtures/greenhouse-board-with-malformed-record.json
new file mode 100644
index 0000000..b75f5a9
--- /dev/null
+++ b/ingestion/test/fixtures/greenhouse-board-with-malformed-record.json
@@ -0,0 +1,21 @@
+{
+ "jobs": [
+ {
+ "id": 2000001,
+ "title": "Working Student, Backend",
+ "absolute_url": "https://job-boards.greenhouse.io/synthetic-example/jobs/2000001",
+ "location": { "name": "Bremen, Germany" },
+ "updated_at": "2026-07-01T09:00:00Z",
+ "first_published": "2026-06-15T09:00:00Z",
+ "company_name": "Synthetic Example GmbH",
+ "content": "Backend work in Bremen.
",
+ "departments": [],
+ "offices": []
+ },
+ {
+ "title": "Missing an id field entirely",
+ "absolute_url": "https://job-boards.greenhouse.io/synthetic-example/jobs/2000002",
+ "content": "This record is intentionally malformed.
"
+ }
+ ]
+}
diff --git a/ingestion/test/identity.test.ts b/ingestion/test/identity.test.ts
new file mode 100644
index 0000000..c4af634
--- /dev/null
+++ b/ingestion/test/identity.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from 'vitest'
+import { computeContentHash, normalizeUrl } from '../src/identity.js'
+import type { NormalizedListing } from '../src/adapters/types.js'
+
+function listing(
+ overrides: Partial = {},
+): NormalizedListing {
+ return {
+ externalId: '1',
+ canonicalUrl: 'https://example.test/jobs/1',
+ applicationUrl: 'https://example.test/jobs/1',
+ title: 'Working Student',
+ organization: 'Example GmbH',
+ description: 'Description text.',
+ locationText: 'Bremen, Germany',
+ country: null,
+ region: null,
+ city: null,
+ remoteMode: 'onsite',
+ opportunityKind: 'working_student',
+ employmentType: 'internship',
+ postedAt: '2026-06-01T00:00:00Z',
+ applicationDeadline: null,
+ sourceUpdatedAt: '2026-06-01T00:00:00Z',
+ sourceMetadata: {},
+ ...overrides,
+ }
+}
+
+describe('normalizeUrl', () => {
+ it('lowercases scheme and host', () => {
+ expect(normalizeUrl('HTTPS://Example.TEST/Jobs/1')).toBe(
+ 'https://example.test/Jobs/1',
+ )
+ })
+
+ it('strips the fragment and default port', () => {
+ expect(normalizeUrl('https://example.test:443/jobs/1#apply')).toBe(
+ 'https://example.test/jobs/1',
+ )
+ })
+
+ it('collapses duplicate slashes and strips one trailing slash', () => {
+ expect(normalizeUrl('https://example.test//jobs//1/')).toBe(
+ 'https://example.test/jobs/1',
+ )
+ })
+
+ it('is stable across equivalent inputs', () => {
+ const a = normalizeUrl('https://example.test/jobs/1/')
+ const b = normalizeUrl('HTTPS://EXAMPLE.test/jobs/1#section')
+ expect(a).toBe(b)
+ })
+})
+
+describe('computeContentHash', () => {
+ it('is deterministic for identical listings', () => {
+ expect(computeContentHash(listing())).toBe(computeContentHash(listing()))
+ })
+
+ it('changes when a historically-meaningful field changes', () => {
+ const base = computeContentHash(listing())
+ const changed = computeContentHash(listing({ description: 'Different.' }))
+ expect(changed).not.toBe(base)
+ })
+
+ it('does not change when only source_metadata differs', () => {
+ const a = computeContentHash(
+ listing({ sourceMetadata: { departments: ['A'] } }),
+ )
+ const b = computeContentHash(
+ listing({ sourceMetadata: { departments: ['B'] } }),
+ )
+ expect(a).toBe(b)
+ })
+
+ it('does not collide when a value shifts across the field boundary', () => {
+ const a = computeContentHash(listing({ title: 'AB', organization: 'CD' }))
+ const b = computeContentHash(listing({ title: 'A', organization: 'BCD' }))
+ expect(a).not.toBe(b)
+ })
+})
diff --git a/ingestion/test/orchestrator.test.ts b/ingestion/test/orchestrator.test.ts
new file mode 100644
index 0000000..2bd9173
--- /dev/null
+++ b/ingestion/test/orchestrator.test.ts
@@ -0,0 +1,312 @@
+import { describe, expect, it } from 'vitest'
+import { ingestDryRun, ingestReal } from '../src/orchestrator.js'
+import type { NormalizedListing, SourceRow } from '../src/adapters/types.js'
+
+interface FakeSourceListingRow {
+ id: string
+ opportunity_id: string
+ currentVersionId: string | null
+}
+
+interface RpcCall {
+ name: string
+ args: Record
+}
+
+class FakeQueryBuilder {
+ private filters: Record = {}
+ constructor(
+ private readonly table: string,
+ private readonly resolve: (
+ table: string,
+ filters: Record,
+ single: boolean,
+ ) => { data: unknown; error: unknown },
+ ) {}
+
+ select(): this {
+ return this
+ }
+
+ eq(column: string, value: unknown): this {
+ this.filters[column] = value
+ return this
+ }
+
+ maybeSingle(): Promise<{ data: unknown; error: unknown }> {
+ return Promise.resolve(this.resolve(this.table, this.filters, true))
+ }
+
+ then(
+ onfulfilled: (value: { data: unknown; error: unknown }) => T,
+ onrejected?: (reason: unknown) => T,
+ ): Promise {
+ return Promise.resolve(this.resolve(this.table, this.filters, false)).then(
+ onfulfilled,
+ onrejected,
+ )
+ }
+}
+
+function createFakeClient(options: {
+ sourceListings?: Map
+ versionHashes?: Map
+ activeListings?: { external_id: string; absence_count: number }[]
+ rpcHandler?: (
+ name: string,
+ args: Record,
+ ) => { data: unknown; error: unknown }
+}) {
+ const rpcCalls: RpcCall[] = []
+ const sourceListings = options.sourceListings ?? new Map()
+ const versionHashes = options.versionHashes ?? new Map()
+ const activeListings = options.activeListings ?? []
+
+ const client = {
+ from(table: string) {
+ return new FakeQueryBuilder(table, (t, filters, single) => {
+ if (t === 'source_listings' && single) {
+ const row = sourceListings.get(filters.external_id as string)
+ if (!row) return { data: null, error: null }
+ return {
+ data: {
+ id: row.id,
+ opportunity_id: row.opportunity_id,
+ opportunities: { current_version_id: row.currentVersionId },
+ },
+ error: null,
+ }
+ }
+ if (t === 'opportunity_versions' && single) {
+ const hash = versionHashes.get(filters.id as string)
+ return { data: hash ? { content_hash: hash } : null, error: null }
+ }
+ if (t === 'source_listings' && !single) {
+ return { data: activeListings, error: null }
+ }
+ return { data: null, error: null }
+ })
+ },
+ rpc(name: string, args: Record) {
+ rpcCalls.push({ name, args })
+ if (options.rpcHandler)
+ return Promise.resolve(options.rpcHandler(name, args))
+ return Promise.resolve({ data: null, error: null })
+ },
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any
+
+ return { client, rpcCalls }
+}
+
+const source: SourceRow = {
+ id: 'source-1',
+ source_key: 'greenhouse:synthetic-example',
+ adapter_kind: 'greenhouse',
+ display_name: 'Synthetic Example',
+ base_url: 'https://boards-api.greenhouse.io/v1/boards/synthetic-example/jobs',
+ enabled: true,
+}
+
+function baseListing(id: string, title: string): NormalizedListing {
+ return {
+ externalId: id,
+ canonicalUrl: `https://example.test/jobs/${id}`,
+ applicationUrl: `https://example.test/jobs/${id}`,
+ title,
+ organization: 'Synthetic Example',
+ description: 'Body.',
+ locationText: 'Bremen',
+ country: null,
+ region: null,
+ city: null,
+ remoteMode: 'onsite',
+ opportunityKind: 'internship',
+ employmentType: 'internship',
+ postedAt: null,
+ applicationDeadline: null,
+ sourceUpdatedAt: null,
+ sourceMetadata: {},
+ }
+}
+
+describe('ingestDryRun', () => {
+ it('performs zero writes -- no rpc call is ever made', async () => {
+ const { client, rpcCalls } = createFakeClient({})
+ await ingestDryRun({
+ client,
+ source,
+ records: [{ id: 'a' }],
+ pageComplete: true,
+ parse: (raw) => raw,
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+ expect(rpcCalls).toHaveLength(0)
+ })
+
+ it('classifies an unseen external id as would-create', async () => {
+ const { client } = createFakeClient({})
+ const summary = await ingestDryRun({
+ client,
+ source,
+ records: [{ id: 'new-1' }],
+ pageComplete: true,
+ parse: (raw) => raw,
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+ expect(summary.wouldCreate).toBe(1)
+ expect(summary.wouldUpdate).toBe(0)
+ expect(summary.wouldBeUnchanged).toBe(0)
+ })
+
+ it('classifies an existing listing with a changed hash as would-update', async () => {
+ const sourceListings = new Map([
+ [
+ 'existing-1',
+ { id: 'sl-1', opportunity_id: 'opp-1', currentVersionId: 'v-1' },
+ ],
+ ])
+ const versionHashes = new Map([['v-1', 'stale-hash']])
+ const { client } = createFakeClient({ sourceListings, versionHashes })
+ const summary = await ingestDryRun({
+ client,
+ source,
+ records: [{ id: 'existing-1' }],
+ pageComplete: true,
+ parse: (raw) => raw,
+ normalize: (raw) =>
+ baseListing((raw as { id: string }).id, 'Changed Title'),
+ })
+ expect(summary.wouldUpdate).toBe(1)
+ })
+
+ it('isolates a single malformed record without failing the whole run', async () => {
+ const { client } = createFakeClient({})
+ const summary = await ingestDryRun({
+ client,
+ source,
+ records: [{ id: 'ok-1' }, { bad: true }],
+ pageComplete: true,
+ parse: (raw) => {
+ if (!('id' in (raw as object))) throw new Error('missing id')
+ return raw
+ },
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+ expect(summary.wouldCreate).toBe(1)
+ expect(summary.errorCount).toBe(1)
+ })
+})
+
+describe('ingestReal', () => {
+ it('begins a run, applies each parseable record once, and finalizes with status complete', async () => {
+ const { client, rpcCalls } = createFakeClient({
+ rpcHandler: (name) => {
+ if (name === 'begin_ingestion_run')
+ return { data: 'run-1', error: null }
+ if (name === 'apply_source_listing') {
+ return {
+ data: [
+ {
+ outcome: 'created',
+ opportunity_id: 'opp-x',
+ source_listing_id: 'sl-x',
+ opportunity_version_id: 'v-x',
+ },
+ ],
+ error: null,
+ }
+ }
+ return { data: null, error: null }
+ },
+ })
+
+ const summary = await ingestReal({
+ client,
+ source,
+ records: [{ id: 'a' }, { id: 'b' }],
+ pageComplete: true,
+ parse: (raw) => raw,
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+
+ expect(summary.status).toBe('complete')
+ expect(summary.recordsNew).toBe(2)
+ expect(
+ rpcCalls.filter((c) => c.name === 'apply_source_listing'),
+ ).toHaveLength(2)
+ expect(
+ rpcCalls.filter((c) => c.name === 'begin_ingestion_run'),
+ ).toHaveLength(1)
+ const finalizeCall = rpcCalls.find(
+ (c) => c.name === 'finalize_ingestion_run',
+ )
+ expect(finalizeCall?.args.p_status).toBe('complete')
+ expect(finalizeCall?.args.p_run_id).toBe('run-1')
+ })
+
+ it('finalizes with status partial when the page was not complete', async () => {
+ const { client, rpcCalls } = createFakeClient({
+ rpcHandler: (name) => {
+ if (name === 'begin_ingestion_run')
+ return { data: 'run-2', error: null }
+ return { data: null, error: null }
+ },
+ })
+
+ await ingestReal({
+ client,
+ source,
+ records: [],
+ pageComplete: false,
+ parse: (raw) => raw,
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+
+ const finalizeCall = rpcCalls.find(
+ (c) => c.name === 'finalize_ingestion_run',
+ )
+ expect(finalizeCall?.args.p_status).toBe('partial')
+ })
+
+ it('isolates a single malformed record and still applies the rest', async () => {
+ const applied: unknown[] = []
+ const { client } = createFakeClient({
+ rpcHandler: (name, args) => {
+ if (name === 'begin_ingestion_run')
+ return { data: 'run-3', error: null }
+ if (name === 'apply_source_listing') {
+ applied.push(args.p_external_id)
+ return {
+ data: [
+ {
+ outcome: 'created',
+ opportunity_id: 'opp',
+ source_listing_id: 'sl',
+ opportunity_version_id: 'v',
+ },
+ ],
+ error: null,
+ }
+ }
+ return { data: null, error: null }
+ },
+ })
+
+ const summary = await ingestReal({
+ client,
+ source,
+ records: [{ id: 'good-1' }, { bad: true }],
+ pageComplete: true,
+ parse: (raw) => {
+ if (!('id' in (raw as object))) throw new Error('missing id')
+ return raw
+ },
+ normalize: (raw) => baseListing((raw as { id: string }).id, 'Intern'),
+ })
+
+ expect(applied).toEqual(['good-1'])
+ expect(summary.recordsNew).toBe(1)
+ expect(summary.errorCount).toBe(1)
+ })
+})
diff --git a/ingestion/test/sanitize.test.ts b/ingestion/test/sanitize.test.ts
new file mode 100644
index 0000000..1dceee1
--- /dev/null
+++ b/ingestion/test/sanitize.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import { sanitizeDescription } from '../src/sanitize.js'
+
+describe('sanitizeDescription', () => {
+ it('strips script, style, iframe, and event handlers entirely', () => {
+ const result = sanitizeDescription(
+ 'Hello and welcome.
' +
+ '',
+ )
+ expect(result).not.toContain('