add jobs pages

This commit is contained in:
ordinarthur 2026-04-15 18:50:50 +02:00
parent bc0c15873f
commit eb430b59e6
17 changed files with 9070 additions and 25 deletions

29
AGENTS.md Normal file
View File

@ -0,0 +1,29 @@
# AGENTS.md — ordinarthur-os
Avant toute action, lire dans cet ordre :
1. [`README.md`](./README.md)
2. [`PLAN.md`](./PLAN.md)
3. [`ARCHITECTURE.md`](./ARCHITECTURE.md)
## Règles non-négociables
- **Pas de Next.js, pas de Vercel.** Stack = Vite/React + NestJS, déploiement k3s.
- **L'IA ne mute jamais la DB sans clic de confirmation utilisateur.** Le backend renvoie des `ProposedAction[]`, la PWA confirme via modal, puis `/ai/command/confirm` exécute.
- **Single-user.** Bearer token statique, pas de multi-tenant.
- **Design = portfolio arthurbarre.fr** (cream `#F5F1EA`, ink `#0F0F0F`, accent orange `#FF4A1C`, mono labels, bordures, italique = orange). PAS le violet/cyan du HTML jobs.
- **FR only** côté IA (prompts + STT lang=fr).
- **Schéma Postgres dédié `ordinarthur_os`** dans Supabase self-hosted (`supabase.arthurbarre.fr`).
## Phases
Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) reportée.
## Conventions repo
- Monorepo pnpm + Turborepo
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM)
- `packages/shared` types + zod DTOs partagés PWA ↔ API
- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant
- Pas de fichier `.env` commité, juste `.env.example`

View File

@ -4,9 +4,10 @@ import { DbModule } from "./db/db.module";
import { HealthModule } from "./modules/health/health.module"; import { HealthModule } from "./modules/health/health.module";
import { AuthModule } from "./modules/auth/auth.module"; import { AuthModule } from "./modules/auth/auth.module";
import { BearerMiddleware } from "./modules/auth/bearer.middleware"; import { BearerMiddleware } from "./modules/auth/bearer.middleware";
import { JobsModule } from "./modules/jobs/jobs.module";
@Module({ @Module({
imports: [ConfigModule, DbModule, HealthModule, AuthModule], imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {

View File

@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod"; import { z } from "zod";
const EnvSchema = z.object({ const EnvSchema = z.object({
@ -10,7 +12,6 @@ const EnvSchema = z.object({
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"), SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
// Phase 5+ — optionnels jusque-là
MISTRAL_API_KEY: z.string().optional(), MISTRAL_API_KEY: z.string().optional(),
MISTRAL_MODEL: z.string().default("mistral-small-latest"), MISTRAL_MODEL: z.string().default("mistral-small-latest"),
GROQ_API_KEY: z.string().optional(), GROQ_API_KEY: z.string().optional(),
@ -31,12 +32,53 @@ let cached: AppConfig | null = null;
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
if (cached) return cached; if (cached) return cached;
loadEnvFile();
const parsed = EnvSchema.safeParse(process.env); const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) { if (!parsed.success) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors); console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
throw new Error("Invalid environment configuration"); throw new Error("Invalid environment configuration");
} }
cached = parsed.data; cached = parsed.data;
return cached; return cached;
} }
function loadEnvFile() {
const candidates = [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "apps/api/.env"),
path.resolve(__dirname, "../../.env"),
];
const envPath = candidates.find((candidate) => existsSync(candidate));
if (!envPath) return;
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (!key || process.env[key] !== undefined) continue;
process.env[key] = stripWrappingQuotes(value);
}
}
function stripWrappingQuotes(value: string) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}

View File

@ -4,7 +4,7 @@ import { APP_CONFIG } from "../config/config.module";
import type { AppConfig } from "../config/env"; import type { AppConfig } from "../config/env";
export const SUPABASE = Symbol("SUPABASE"); export const SUPABASE = Symbol("SUPABASE");
export type Supabase = SupabaseClient; export type Supabase = SupabaseClient<any, any, any, any, any>;
@Global() @Global()
@Module({ @Module({
@ -12,7 +12,7 @@ export type Supabase = SupabaseClient;
{ {
provide: SUPABASE, provide: SUPABASE,
inject: [APP_CONFIG], inject: [APP_CONFIG],
useFactory: (config: AppConfig): SupabaseClient => useFactory: (config: AppConfig): Supabase =>
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false }, auth: { persistSession: false, autoRefreshToken: false },
db: { schema: config.SUPABASE_SCHEMA }, db: { schema: config.SUPABASE_SCHEMA },

View File

@ -0,0 +1,16 @@
import { BadRequestException, PipeTransform } from "@nestjs/common";
import type { ZodSchema } from "zod";
export class ZodPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
transform(value: unknown): T {
const parsed = this.schema.safeParse(value);
if (!parsed.success) {
throw new BadRequestException({
message: "Validation failed",
issues: parsed.error.flatten(),
});
}
return parsed.data;
}
}

View File

@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from "@nestjs/common";
import {
JobIngestRequest,
JobListQuery,
JobPatchDto,
JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "../../lib/zod-pipe";
import { JobsService } from "./jobs.service";
@Controller("jobs")
export class JobsController {
constructor(private readonly jobs: JobsService) {}
@Post("ingest")
ingest(@Body(new ZodPipe(JobIngestRequest)) body: JobIngestRequest) {
return this.jobs.ingest(body.jobs);
}
@Get()
list(@Query(new ZodPipe(JobListQuery)) q: JobListQuery) {
return this.jobs.list(q);
}
@Patch(":id")
patch(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(JobPatchDto)) patch: JobPatchDto,
) {
return this.jobs.patch(id, patch);
}
// ---- criteria ----------------------------------------------------------
@Get("criteria")
listCriteria(@Query("active") active?: string) {
return this.jobs.listCriteria(active === "true");
}
@Post("criteria")
createCriteria(@Body(new ZodPipe(JobSearchCriteriaUpsert)) input: JobSearchCriteriaUpsert) {
return this.jobs.createCriteria(input);
}
@Put("criteria/:id")
updateCriteria(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(JobSearchCriteriaUpsert)) input: JobSearchCriteriaUpsert,
) {
return this.jobs.updateCriteria(id, input);
}
@Delete("criteria/:id")
deleteCriteria(@Param("id", new ParseUUIDPipe()) id: string) {
return this.jobs.deleteCriteria(id);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { JobsController } from "./jobs.controller";
import { JobsService } from "./jobs.service";
@Module({
controllers: [JobsController],
providers: [JobsService],
})
export class JobsModule {}

View File

@ -0,0 +1,149 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
Job,
JobIngestDto,
JobIngestResponse,
JobListQuery,
JobPatchDto,
JobSearchCriteria,
JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared";
import { InjectSupabase, type Supabase } from "../../db/db.module";
const RETENTION_DAYS = 30;
@Injectable()
export class JobsService {
constructor(@InjectSupabase() private readonly db: Supabase) {}
async ingest(jobs: JobIngestDto[]): Promise<JobIngestResponse> {
let inserted = 0;
let updated = 0;
// On traite job par job pour distinguer insert vs update.
// Volume attendu : ~quelques dizaines/jour, c'est OK.
for (const j of jobs) {
const { data: existing } = await this.db
.from("jobs")
.select("id")
.eq("source_url", j.source_url)
.maybeSingle();
if (existing) {
await this.db
.from("jobs")
.update({
title: j.title,
company: j.company ?? null,
description: j.description ?? null,
location: j.location ?? null,
remote_type: j.remote_type ?? null,
salary_min: j.salary_min ?? null,
salary_max: j.salary_max ?? null,
stack: j.stack ?? [],
apply_url: j.apply_url ?? null,
last_seen_at: new Date().toISOString(),
})
.eq("id", existing.id);
updated++;
} else {
const { error } = await this.db.from("jobs").insert({
source: j.source,
source_url: j.source_url,
title: j.title,
company: j.company ?? null,
description: j.description ?? null,
location: j.location ?? null,
remote_type: j.remote_type ?? null,
salary_min: j.salary_min ?? null,
salary_max: j.salary_max ?? null,
stack: j.stack ?? [],
apply_url: j.apply_url ?? null,
});
if (error) throw error;
inserted++;
}
}
const archived = await this.archiveStale();
return { inserted, updated, archived };
}
/** Soft-delete des jobs non revus depuis RETENTION_DAYS jours. */
private async archiveStale(): Promise<number> {
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000).toISOString();
const { data, error } = await this.db
.from("jobs")
.update({ archived: true })
.lt("last_seen_at", cutoff)
.eq("archived", false)
.select("id");
if (error) throw error;
return data?.length ?? 0;
}
async list(q: JobListQuery): Promise<Job[]> {
let query = this.db.from("jobs").select("*").order("last_seen_at", { ascending: false });
if (typeof q.archived === "boolean") query = query.eq("archived", q.archived);
else query = query.eq("archived", false); // défaut : non archivés
if (q.remote_type) query = query.eq("remote_type", q.remote_type);
if (q.starred !== undefined) query = query.eq("starred", q.starred);
if (q.since) query = query.gte("last_seen_at", q.since);
if (q.stack?.length) query = query.overlaps("stack", q.stack);
const { data, error } = await query.limit(500);
if (error) throw error;
return (data ?? []) as Job[];
}
async patch(id: string, patch: JobPatchDto): Promise<Job> {
const { data, error } = await this.db
.from("jobs")
.update(patch)
.eq("id", id)
.select("*")
.maybeSingle();
if (error) throw error;
if (!data) throw new NotFoundException(`Job ${id} not found`);
return data as Job;
}
// ---- criteria ----------------------------------------------------------
async listCriteria(activeOnly = false): Promise<JobSearchCriteria[]> {
let q = this.db.from("job_search_criteria").select("*").order("created_at", { ascending: true });
if (activeOnly) q = q.eq("active", true);
const { data, error } = await q;
if (error) throw error;
return (data ?? []) as JobSearchCriteria[];
}
async createCriteria(input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
const { data, error } = await this.db
.from("job_search_criteria")
.insert(input)
.select("*")
.single();
if (error) throw error;
return data as JobSearchCriteria;
}
async updateCriteria(id: string, input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
const { data, error } = await this.db
.from("job_search_criteria")
.update({ ...input, updated_at: new Date().toISOString() })
.eq("id", id)
.select("*")
.maybeSingle();
if (error) throw error;
if (!data) throw new NotFoundException(`Criteria ${id} not found`);
return data as JobSearchCriteria;
}
async deleteCriteria(id: string): Promise<void> {
const { error } = await this.db.from("job_search_criteria").delete().eq("id", id);
if (error) throw error;
}
}

View File

@ -3,7 +3,9 @@
* Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding). * Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding).
*/ */
const BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const BASE =
import.meta.env.VITE_API_BASE_URL ??
(import.meta.env.DEV ? "http://localhost:3000" : "");
const TOKEN_KEY = "ordinarthur.bearer"; const TOKEN_KEY = "ordinarthur.bearer";
export function getToken(): string | null { export function getToken(): string | null {

View File

@ -1,26 +1,95 @@
/* prettier-ignore-start */
/* eslint-disable */ /* eslint-disable */
// @ts-nocheck // @ts-nocheck
// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`. // noinspection JSUnusedGlobalSymbols
// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run.
import { Route as rootRoute } from "./routes/__root"; // This file was automatically generated by TanStack Router.
import { Route as IndexRoute } from "./routes/index"; // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
const IndexRouteWithParent = IndexRoute.update({ import { Route as rootRouteImport } from './routes/__root'
path: "/", import { Route as JobsRouteImport } from './routes/jobs'
getParentRoute: () => rootRoute, import { Route as IndexRouteImport } from './routes/index'
} as any); import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
export const routeTree = rootRoute.addChildren({ const JobsRoute = JobsRouteImport.update({
IndexRoute: IndexRouteWithParent, id: '/jobs',
} as any); path: '/jobs',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsJobsRoute = SettingsJobsRouteImport.update({
id: '/settings/jobs',
path: '/settings/jobs',
getParentRoute: () => rootRouteImport,
} as any)
declare module "@tanstack/react-router" { export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/jobs': typeof JobsRoute
'/settings/jobs': typeof SettingsJobsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/jobs': typeof JobsRoute
'/settings/jobs': typeof SettingsJobsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/jobs': typeof JobsRoute
'/settings/jobs': typeof SettingsJobsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/jobs' | '/settings/jobs'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/jobs' | '/settings/jobs'
id: '__root__' | '/' | '/jobs' | '/settings/jobs'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
JobsRoute: typeof JobsRoute
SettingsJobsRoute: typeof SettingsJobsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
"/": { parentRoute: typeof rootRoute }; '/jobs': {
id: '/jobs'
path: '/jobs'
fullPath: '/jobs'
preLoaderRoute: typeof JobsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/jobs': {
id: '/settings/jobs'
path: '/settings/jobs'
fullPath: '/settings/jobs'
preLoaderRoute: typeof SettingsJobsRouteImport
parentRoute: typeof rootRouteImport
}
} }
} }
/* prettier-ignore-end */ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
JobsRoute: JobsRoute,
SettingsJobsRoute: SettingsJobsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@ -15,8 +15,8 @@ function RootLayout() {
<Label className="text-ink">ORDINARTHUR-OS</Label> <Label className="text-ink">ORDINARTHUR-OS</Label>
</Link> </Link>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
{/* Routes activées au fur et à mesure des phases */}
<NavLink to="/">Dashboard</NavLink> <NavLink to="/">Dashboard</NavLink>
<NavLink to="/jobs">Jobs</NavLink>
</nav> </nav>
</div> </div>
</header> </header>
@ -25,7 +25,7 @@ function RootLayout() {
</main> </main>
<footer className="border-t border-ink"> <footer className="border-t border-ink">
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3"> <div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
<Label>v0.0.0 · phase 0</Label> <Label>v0.0.0 · phase 1</Label>
<Label>arthurbarre.fr</Label> <Label>arthurbarre.fr</Label>
</div> </div>
</footer> </footer>

View File

@ -15,7 +15,7 @@ function Dashboard() {
return ( return (
<div className="space-y-12"> <div className="space-y-12">
<section> <section>
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label> <Label prefix="[ 00 ]">PHASE 0 1</Label>
<BigHeading className="mt-4"> <BigHeading className="mt-4">
Un assistant <em>qui n'agit jamais</em> sans ton clic. Un assistant <em>qui n'agit jamais</em> sans ton clic.
</BigHeading> </BigHeading>
@ -57,8 +57,8 @@ function Dashboard() {
<Label prefix="[ 02 ]">ROADMAP</Label> <Label prefix="[ 02 ]">ROADMAP</Label>
</div> </div>
<div className="px-4"> <div className="px-4">
<MetaRow label="PHASE 0" value="Scaffold (en cours)" /> <MetaRow label="PHASE 0" value="Scaffold socle en place" />
<MetaRow label="PHASE 1" value="Jobs · prio remontée" /> <MetaRow label="PHASE 1" value="Jobs · première livraison utilisable" />
<MetaRow label="PHASE 2" value="Todos riches" /> <MetaRow label="PHASE 2" value="Todos riches" />
<MetaRow label="PHASE 3" value="Projets + Kanban" /> <MetaRow label="PHASE 3" value="Projets + Kanban" />
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" /> <MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
@ -67,6 +67,29 @@ function Dashboard() {
<MetaRow label="PHASE 7" value="Health tab" /> <MetaRow label="PHASE 7" value="Health tab" />
</div> </div>
</section> </section>
<GridFrame cols={2}>
<div className="p-6 space-y-4">
<Label prefix="[ 03 ]">PRIORITÉ</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
La Phase 1 est la première verticale métier: ingestion backend, listing éditorial,
filtres de recherche et critères intégrés directement dans la vue jobs.
</p>
<a
href="/jobs"
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
>
Ouvrir les jobs
</a>
</div>
<div className="p-6 space-y-4">
<Label prefix="[ 04 ]">MODE</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
Les presets de critères vivent maintenant dans le header de la page jobs pour rester
au plus près du tri quotidien.
</p>
</div>
</GridFrame>
</div> </div>
); );
} }

View File

@ -0,0 +1,483 @@
import { useEffect, useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Job,
JobListQuery,
JobPatchDto,
JobSearchCriteria,
RemoteType,
} from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, DataChip, GridFrame, Label, SectionHeader } from "@/design";
export const Route = createFileRoute("/jobs")({ component: JobsPage });
const REMOTE_TYPES: Array<{ label: string; value: RemoteType | "all" }> = [
{ label: "Tout", value: "all" },
{ label: "Remote", value: "remote" },
{ label: "Hybrid", value: "hybrid" },
{ label: "Onsite", value: "onsite" },
];
function JobsPage() {
const queryClient = useQueryClient();
const [remoteType, setRemoteType] = useState<RemoteType | "all">("all");
const [starredOnly, setStarredOnly] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [stackFilter, setStackFilter] = useState("");
const [activeCriteriaId, setActiveCriteriaId] = useState<string | null>(null);
const criteria = useQuery({
queryKey: ["job-criteria"],
queryFn: () => api<JobSearchCriteria[]>("/jobs/criteria?active=true"),
});
const activeCriteria = useMemo(
() => criteria.data?.find((item) => item.id === activeCriteriaId) ?? null,
[activeCriteriaId, criteria.data],
);
const stacks = useMemo(() => normalizeTags(stackFilter), [stackFilter]);
const query = useMemo<JobListQuery>(
() => ({
archived: showArchived,
starred: starredOnly || undefined,
remote_type: remoteType === "all" ? undefined : remoteType,
stack: stacks.length ? stacks : undefined,
}),
[remoteType, showArchived, starredOnly, stacks],
);
const jobs = useQuery({
queryKey: ["jobs", query],
queryFn: () => api<Job[]>(`/jobs${toQueryString(query)}`),
});
const patchJob = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: JobPatchDto }) =>
api<Job>(`/jobs/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
});
useEffect(() => {
const firstCriteria = criteria.data?.[0];
if (!firstCriteria) {
setActiveCriteriaId(null);
return;
}
if (!activeCriteriaId) {
setActiveCriteriaId(firstCriteria.id);
}
}, [activeCriteriaId, criteria.data]);
useEffect(() => {
if (!activeCriteria) return;
setRemoteType(
activeCriteria.remote_types.length === 1 && activeCriteria.remote_types[0]
? activeCriteria.remote_types[0]
: "all",
);
setStackFilter(activeCriteria.stack.join(", "));
}, [activeCriteria]);
const visibleJobs = useMemo(
() => (jobs.data ?? []).filter((job) => matchesCriteria(job, activeCriteria)),
[activeCriteria, jobs.data],
);
const stats = getJobStats(visibleJobs);
return (
<div className="space-y-10">
<section className="space-y-4">
<Label prefix="[ 01 ]">PHASE 1 · JOBS</Label>
<BigHeading>
Une veille <em>propre et triable</em> pour les candidatures.
</BigHeading>
<p className="max-w-3xl font-sans text-sm leading-6 text-ink">
Les critères actifs sont maintenant fusionnés dans le header de la page jobs, pour
filtrer rapidement sans détour par un écran de réglages séparé.
</p>
</section>
<SectionHeader
number="02"
label="HEADER FILTERS"
title="Critères actifs, remote, stack, favoris et archivage"
/>
<GridFrame cols={12}>
<div className="col-span-12 border-b border-ink p-4">
<Label>CRITÈRES</Label>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveCriteriaId(null)}
className={filterButtonClass(activeCriteriaId === null)}
>
Tous les jobs
</button>
{criteria.data?.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setActiveCriteriaId(item.id)}
className={filterButtonClass(activeCriteriaId === item.id)}
>
{item.name || "Sans nom"}
</button>
))}
</div>
{activeCriteria ? (
<div className="mt-3 flex flex-wrap gap-2">
{activeCriteria.titles.map((title) => (
<DataChip key={title}>{title}</DataChip>
))}
{activeCriteria.locations.map((location) => (
<DataChip key={location}>{location}</DataChip>
))}
{activeCriteria.stack.map((item) => (
<DataChip key={item}>{item}</DataChip>
))}
{activeCriteria.salary_min ? (
<DataChip>Min {formatSalary(activeCriteria.salary_min, null)}</DataChip>
) : null}
</div>
) : null}
</div>
<div className="col-span-12 border-b border-ink p-4 md:col-span-4 md:border-b-0 md:border-r md:border-ink">
<Label>REMOTE TYPE</Label>
<div className="mt-3 flex flex-wrap gap-2">
{REMOTE_TYPES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setRemoteType(item.value)}
className={filterButtonClass(remoteType === item.value)}
>
{item.label}
</button>
))}
</div>
</div>
<div className="col-span-12 border-b border-ink p-4 md:col-span-4 md:border-b-0 md:border-r md:border-ink">
<Label>STACK</Label>
<input
value={stackFilter}
onChange={(event) => {
setActiveCriteriaId(null);
setStackFilter(event.target.value);
}}
placeholder="react, node, nest"
className="mt-3 w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
<p className="mt-2 font-mono text-[11px] uppercase tracking-label text-muted">
Séparer par virgules pour filtrer sur plusieurs technos
</p>
</div>
<div className="col-span-12 p-4 md:col-span-4">
<Label>OPTIONS</Label>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setStarredOnly((value) => !value)}
className={filterButtonClass(starredOnly)}
>
Favoris
</button>
<button
type="button"
onClick={() => setShowArchived((value) => !value)}
className={filterButtonClass(showArchived)}
>
Archivés
</button>
</div>
</div>
</GridFrame>
<section className="border border-ink">
<div className="border-b border-ink px-4 py-3">
<Label prefix="[ 03 ]">SYNTHÈSE</Label>
</div>
<div className="grid gap-0 md:grid-cols-4 md:divide-x md:divide-ink">
<StatCell label="Résultats" value={String(visibleJobs.length)} />
<StatCell label="Favoris" value={String(stats.starred)} />
<StatCell label="Remote" value={String(stats.remote)} />
<StatCell label="Nouvelles 7j" value={String(stats.fresh)} />
</div>
</section>
<SectionHeader
number="04"
label="LISTE"
title={
jobs.isLoading
? "Chargement des offres"
: `${visibleJobs.length} offres prêtes à être triées`
}
/>
<div className="border border-ink">
<div className="hidden border-b border-ink px-4 py-3 md:grid md:grid-cols-[2.2fr,1.2fr,1fr,1fr,1.2fr] md:gap-4">
<Label>Poste</Label>
<Label>Entreprise</Label>
<Label>Lieu</Label>
<Label>Stack</Label>
<Label>Suivi</Label>
</div>
{jobs.isLoading || criteria.isLoading ? (
<EmptyState text="Connexion au BFF pour charger les jobs et leurs filtres." />
) : jobs.isError ? (
<EmptyState text="Impossible de charger les offres. Vérifie le bearer token et lAPI." />
) : visibleJobs.length === 0 ? (
<EmptyState text="Aucun job ne correspond à ces filtres pour le moment." />
) : (
<div className="divide-y divide-ink">
{visibleJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPatch={(patch) => patchJob.mutate({ id: job.id, patch })}
/>
))}
</div>
)}
</div>
</div>
);
}
function JobRow({ job, onPatch }: { job: Job; onPatch: (patch: JobPatchDto) => void }) {
const [notesDraft, setNotesDraft] = useState(job.notes ?? "");
return (
<article className="grid gap-4 px-4 py-4 md:grid-cols-[2.2fr,1.2fr,1fr,1fr,1.2fr] md:items-start">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-sans text-lg font-light text-ink">{job.title}</h3>
{job.starred ? <DataChip dotColor="accent">Starred</DataChip> : null}
{job.applied_at ? <DataChip dotColor="accent">Applied</DataChip> : null}
</div>
<div className="flex flex-wrap gap-2">
<DataChip>{job.source}</DataChip>
{job.remote_type ? (
<DataChip dotColor={job.remote_type === "remote" ? "accent" : "ink"}>
{job.remote_type}
</DataChip>
) : null}
{job.salary_min || job.salary_max ? (
<DataChip>{formatSalary(job.salary_min, job.salary_max)}</DataChip>
) : null}
</div>
{job.description ? (
<p className="max-w-2xl font-sans text-sm leading-6 text-ink/80">
{truncate(job.description, 220)}
</p>
) : null}
<div className="flex flex-wrap gap-3">
{job.apply_url ? (
<a
href={job.apply_url}
target="_blank"
rel="noreferrer"
className="font-mono text-[11px] uppercase tracking-label text-ink underline underline-offset-4"
>
Postuler
</a>
) : null}
<a
href={job.source_url}
target="_blank"
rel="noreferrer"
className="font-mono text-[11px] uppercase tracking-label text-muted underline underline-offset-4"
>
Source
</a>
</div>
</div>
<div className="space-y-2">
<Label>ENTREPRISE</Label>
<p className="font-sans text-sm text-ink">{job.company || "Non précisée"}</p>
<MetaMini label="Vu le" value={formatDate(job.last_seen_at)} />
<MetaMini label="Ajouté le" value={formatDate(job.first_seen_at)} />
</div>
<div className="space-y-2">
<Label>LIEU</Label>
<p className="font-sans text-sm text-ink">{job.location || "Non précisé"}</p>
</div>
<div className="space-y-2">
<Label>STACK</Label>
<div className="flex flex-wrap gap-2">
{job.stack.length ? (
job.stack.map((item) => <DataChip key={item}>{item}</DataChip>)
) : (
<p className="font-sans text-sm text-muted">Non renseignée</p>
)}
</div>
</div>
<div className="space-y-3">
<Label>SUIVI</Label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => onPatch({ starred: !job.starred })}
className={actionButtonClass(job.starred)}
>
{job.starred ? "Unstar" : "Star"}
</button>
<button
type="button"
onClick={() =>
onPatch({ applied_at: job.applied_at ? null : new Date().toISOString() })
}
className={actionButtonClass(Boolean(job.applied_at))}
>
{job.applied_at ? "Retirer applied" : "Marquer applied"}
</button>
<button
type="button"
onClick={() => onPatch({ archived: !job.archived })}
className={actionButtonClass(job.archived)}
>
{job.archived ? "Restaurer" : "Archiver"}
</button>
</div>
<textarea
value={notesDraft}
onChange={(event) => setNotesDraft(event.target.value)}
onBlur={() => {
if (notesDraft !== (job.notes ?? "")) {
onPatch({ notes: notesDraft.trim() ? notesDraft : null });
}
}}
rows={4}
placeholder="Notes de candidature, contact, next step…"
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
</div>
</article>
);
}
function StatCell({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-2 px-4 py-4">
<Label>{label}</Label>
<p className="font-sans text-3xl font-light text-ink">{value}</p>
</div>
);
}
function MetaMini({ label, value }: { label: string; value: string }) {
return (
<div className="border-b border-ink py-2 last:border-b-0">
<p className="font-mono text-[11px] uppercase tracking-label text-muted">{label}</p>
<p className="mt-1 font-sans text-sm text-ink">{value}</p>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="px-4 py-12">
<p className="max-w-xl font-sans text-sm leading-6 text-ink">{text}</p>
</div>
);
}
function getJobStats(jobs: Job[]) {
const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
return {
starred: jobs.filter((job) => job.starred).length,
remote: jobs.filter((job) => job.remote_type === "remote").length,
fresh: jobs.filter((job) => new Date(job.last_seen_at).getTime() >= oneWeekAgo).length,
};
}
function toQueryString(query: JobListQuery) {
const params = new URLSearchParams();
if (typeof query.archived === "boolean") params.set("archived", String(query.archived));
if (typeof query.starred === "boolean") params.set("starred", String(query.starred));
if (query.remote_type) params.set("remote_type", query.remote_type);
query.stack?.forEach((value) => params.append("stack", value));
const serialized = params.toString();
return serialized ? `?${serialized}` : "";
}
function normalizeTags(value: string) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function truncate(value: string, length: number) {
return value.length <= length ? value : `${value.slice(0, length).trimEnd()}`;
}
function formatDate(value: string | null) {
if (!value) return "—";
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
}
function formatSalary(min?: number | null, max?: number | null) {
const formatter = new Intl.NumberFormat("fr-FR");
if (min && max) return `${formatter.format(min)}${formatter.format(max)}`;
if (min) return `dès ${formatter.format(min)}`;
if (max) return `jusquà ${formatter.format(max)}`;
return "—";
}
function filterButtonClass(active: boolean) {
return [
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
active ? "border-ink bg-ink text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
].join(" ");
}
function actionButtonClass(active: boolean) {
return [
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
active ? "border-accent bg-accent text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
].join(" ");
}
function matchesCriteria(job: Job, criteria: JobSearchCriteria | null) {
if (!criteria) return true;
const title = job.title.toLowerCase();
const location = (job.location ?? "").toLowerCase();
const titleMatch =
criteria.titles.length === 0 ||
criteria.titles.some((item) => title.includes(item.toLowerCase()));
const locationMatch =
criteria.locations.length === 0 ||
criteria.locations.some((item) => location.includes(item.toLowerCase()));
const remoteMatch =
criteria.remote_types.length === 0 ||
(job.remote_type ? criteria.remote_types.includes(job.remote_type) : false);
const salaryMatch =
!criteria.salary_min || (job.salary_max ?? job.salary_min ?? 0) >= criteria.salary_min;
return titleMatch && locationMatch && remoteMatch && salaryMatch;
}

View File

@ -0,0 +1,387 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { JobSearchCriteria, JobSearchCriteriaUpsert, RemoteType } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, DataChip, GridFrame, Label, SectionHeader } from "@/design";
export const Route = createFileRoute("/settings/jobs")({ component: JobCriteriaPage });
const DEFAULT_FORM: JobSearchCriteriaUpsert = {
name: "",
titles: [],
locations: [],
stack: [],
remote_types: [],
salary_min: null,
active: true,
};
const REMOTE_TYPES: RemoteType[] = ["remote", "hybrid", "onsite"];
function JobCriteriaPage() {
const queryClient = useQueryClient();
const [createForm, setCreateForm] = useState<CriteriaFormState>(toFormState(DEFAULT_FORM));
const criteria = useQuery({
queryKey: ["job-criteria"],
queryFn: () => api<JobSearchCriteria[]>("/jobs/criteria"),
});
const createCriteria = useMutation({
mutationFn: (payload: JobSearchCriteriaUpsert) =>
api<JobSearchCriteria>("/jobs/criteria", {
method: "POST",
body: JSON.stringify(payload),
}),
onSuccess: () => {
setCreateForm(toFormState(DEFAULT_FORM));
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
},
});
const updateCriteria = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: JobSearchCriteriaUpsert }) =>
api<JobSearchCriteria>(`/jobs/criteria/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
},
});
const deleteCriteria = useMutation({
mutationFn: (id: string) => api<void>(`/jobs/criteria/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["job-criteria"] });
},
});
return (
<div className="space-y-10">
<section className="space-y-4">
<Label prefix="[ 01 ]">SETTINGS · JOBS</Label>
<BigHeading>
Les critères restent <em>visibles et éditables</em>.
</BigHeading>
<p className="max-w-3xl font-sans text-sm leading-6 text-ink">
Chaque critère alimente lingestion quotidienne hors repo. Ici, la PWA sert de cockpit
simple pour affiner titres, localisations, stack, remote et seuil de salaire.
</p>
</section>
<SectionHeader
number="02"
label="NOUVEAU"
title="Ajouter un critère de veille"
/>
<CriteriaForm
key="create"
mode="create"
form={createForm}
onChange={setCreateForm}
onSubmit={() => createCriteria.mutate(toPayload(createForm))}
busy={createCriteria.isPending}
/>
<SectionHeader
number="03"
label="CRITÈRES"
title={
criteria.isLoading
? "Chargement des critères"
: `${criteria.data?.length ?? 0} critères enregistrés`
}
/>
{criteria.isLoading ? (
<div className="border border-ink px-4 py-10">
<p className="font-sans text-sm text-ink">Lecture des critères en cours.</p>
</div>
) : criteria.isError ? (
<div className="border border-ink px-4 py-10">
<p className="font-sans text-sm text-ink">
Impossible de charger les critères. Vérifie lAPI et le bearer token.
</p>
</div>
) : (
<div className="space-y-6">
{criteria.data?.map((item) => (
<EditableCriteriaCard
key={item.id}
criteria={item}
onSave={(payload) => updateCriteria.mutate({ id: item.id, payload })}
onDelete={() => deleteCriteria.mutate(item.id)}
saving={updateCriteria.isPending}
deleting={deleteCriteria.isPending}
/>
))}
{criteria.data?.length === 0 ? (
<div className="border border-ink px-4 py-10">
<p className="font-sans text-sm text-ink">
Aucun critère pour linstant. Ajoute-en un pour alimenter lingestion quotidienne.
</p>
</div>
) : null}
</div>
)}
</div>
);
}
function EditableCriteriaCard({
criteria,
onSave,
onDelete,
saving,
deleting,
}: {
criteria: JobSearchCriteria;
onSave: (payload: JobSearchCriteriaUpsert) => void;
onDelete: () => void;
saving: boolean;
deleting: boolean;
}) {
const [form, setForm] = useState<CriteriaFormState>(() => toFormState(criteria));
return (
<div className="border border-ink">
<div className="border-b border-ink px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label prefix="[ LIVE ]">{criteria.name || "CRITÈRE SANS NOM"}</Label>
<div className="flex flex-wrap gap-2">
<DataChip dotColor={criteria.active ? "accent" : "ink"}>
{criteria.active ? "Actif" : "Inactif"}
</DataChip>
<DataChip>Maj {formatDate(criteria.updated_at)}</DataChip>
</div>
</div>
</div>
<CriteriaForm
mode="edit"
form={form}
onChange={setForm}
onSubmit={() => onSave(toPayload(form))}
onDelete={onDelete}
busy={saving || deleting}
/>
</div>
);
}
function CriteriaForm({
mode,
form,
onChange,
onSubmit,
onDelete,
busy,
}: {
mode: "create" | "edit";
form: CriteriaFormState;
onChange: (next: CriteriaFormState) => void;
onSubmit: () => void;
onDelete?: () => void;
busy: boolean;
}) {
return (
<GridFrame cols={12}>
<FormCell label="Nom">
<input
value={form.name}
onChange={(event) => onChange({ ...form, name: event.target.value })}
placeholder="Frontend senior Marseille"
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
</FormCell>
<FormCell label="Titres" className="md:col-span-3">
<TagTextarea
value={form.titles}
placeholder="développeur front, frontend engineer"
onChange={(value) => onChange({ ...form, titles: value })}
/>
</FormCell>
<FormCell label="Localisations" className="md:col-span-3">
<TagTextarea
value={form.locations}
placeholder="Marseille, Aix-en-Provence, remote"
onChange={(value) => onChange({ ...form, locations: value })}
/>
</FormCell>
<FormCell label="Stack" className="md:col-span-3">
<TagTextarea
value={form.stack}
placeholder="react, typescript, nestjs"
onChange={(value) => onChange({ ...form, stack: value })}
/>
</FormCell>
<FormCell label="Salaire min" className="md:col-span-3">
<input
type="number"
min={0}
value={form.salaryMin}
onChange={(event) => onChange({ ...form, salaryMin: event.target.value })}
placeholder="45000"
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
</FormCell>
<FormCell label="Remote" className="md:col-span-6">
<div className="flex flex-wrap gap-2">
{REMOTE_TYPES.map((remoteType) => {
const active = form.remoteTypes.includes(remoteType);
return (
<button
key={remoteType}
type="button"
onClick={() =>
onChange({
...form,
remoteTypes: toggleArrayValue(form.remoteTypes, remoteType),
})
}
className={buttonClass(active)}
>
{remoteType}
</button>
);
})}
</div>
</FormCell>
<FormCell label="Statut" className="md:col-span-6">
<button
type="button"
onClick={() => onChange({ ...form, active: !form.active })}
className={buttonClass(form.active)}
>
{form.active ? "Actif" : "Inactif"}
</button>
</FormCell>
<div className="col-span-12 flex flex-wrap items-center gap-3 p-4">
<button
type="button"
onClick={onSubmit}
disabled={busy}
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg transition-opacity disabled:opacity-50"
>
{mode === "create" ? "Créer le critère" : "Enregistrer"}
</button>
{mode === "edit" && onDelete ? (
<button
type="button"
onClick={onDelete}
disabled={busy}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-opacity disabled:opacity-50"
>
Supprimer
</button>
) : null}
</div>
</GridFrame>
);
}
function FormCell({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={["col-span-12 border-b border-ink p-4 md:col-span-6", className].filter(Boolean).join(" ")}>
<Label>{label}</Label>
<div className="mt-3">{children}</div>
</div>
);
}
function TagTextarea({
value,
placeholder,
onChange,
}: {
value: string;
placeholder: string;
onChange: (value: string) => void;
}) {
return (
<textarea
rows={4}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full border border-ink bg-transparent px-3 py-2 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
);
}
function toPayload(form: CriteriaFormState): JobSearchCriteriaUpsert {
return {
name: form.name.trim() || null,
titles: splitTags(form.titles),
locations: splitTags(form.locations),
stack: splitTags(form.stack),
remote_types: form.remoteTypes,
salary_min: form.salaryMin.trim() ? Number(form.salaryMin) : null,
active: form.active,
};
}
function toFormState(value: JobSearchCriteriaUpsert | JobSearchCriteria): CriteriaFormState {
return {
name: value.name ?? "",
titles: (value.titles ?? []).join(", "),
locations: (value.locations ?? []).join(", "),
stack: (value.stack ?? []).join(", "),
remoteTypes: [...(value.remote_types ?? [])],
salaryMin: value.salary_min?.toString() ?? "",
active: value.active ?? true,
};
}
function splitTags(value: string) {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function toggleArrayValue<T extends string>(values: T[], next: T) {
return values.includes(next) ? values.filter((item) => item !== next) : [...values, next];
}
function buttonClass(active: boolean) {
return [
"border px-3 py-2 font-mono text-[11px] uppercase tracking-label transition-colors",
active ? "border-ink bg-ink text-bg" : "border-ink text-ink hover:bg-ink hover:text-bg",
].join(" ");
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
}
interface CriteriaFormState {
name: string;
titles: string;
locations: string;
stack: string;
remoteTypes: RemoteType[];
salaryMin: string;
active: boolean;
}

View File

@ -0,0 +1,54 @@
-- 0002_jobs.sql — Phase 1
-- Tables: jobs, job_search_criteria
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.jobs (
id uuid primary key default gen_random_uuid(),
source text not null, -- 'Indeed','WeLoveDevs',...
source_url text not null unique, -- clé de dedup
title text not null,
company text,
description text,
location text,
remote_type text check (remote_type in ('remote','hybrid','onsite')),
salary_min int,
salary_max int,
stack text[] not null default '{}',
apply_url text,
first_seen_at timestamptz not null default now(),
last_seen_at timestamptz not null default now(),
archived boolean not null default false,
starred boolean not null default false,
applied_at timestamptz,
notes text
);
create index if not exists jobs_last_seen_idx on ordinarthur_os.jobs(last_seen_at desc);
create index if not exists jobs_archived_idx on ordinarthur_os.jobs(archived);
create index if not exists jobs_remote_type_idx on ordinarthur_os.jobs(remote_type);
create index if not exists jobs_stack_gin on ordinarthur_os.jobs using gin (stack);
alter table ordinarthur_os.jobs enable row level security;
drop policy if exists jobs_service_role on ordinarthur_os.jobs;
create policy jobs_service_role on ordinarthur_os.jobs
for all using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');
create table if not exists ordinarthur_os.job_search_criteria (
id uuid primary key default gen_random_uuid(),
name text,
titles text[] not null default '{}',
locations text[] not null default '{}',
stack text[] not null default '{}',
remote_types text[] not null default '{}',
salary_min int,
active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists job_criteria_active_idx on ordinarthur_os.job_search_criteria(active);
alter table ordinarthur_os.job_search_criteria enable row level security;
drop policy if exists job_criteria_service_role on ordinarthur_os.job_search_criteria;
create policy job_criteria_service_role on ordinarthur_os.job_search_criteria
for all using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');

View File

@ -23,6 +23,96 @@ export type AuthVerifyResponse = z.infer<typeof AuthVerifyResponse>;
export const RemoteType = z.enum(["remote", "hybrid", "onsite"]); export const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
export type RemoteType = z.infer<typeof RemoteType>; export type RemoteType = z.infer<typeof RemoteType>;
// ---------------------------------------------------------------------------
// Phase 1 — Jobs
// ---------------------------------------------------------------------------
export const JobIngestDto = z.object({
source: z.string().min(1),
source_url: z.string().url(),
title: z.string().min(1),
company: z.string().optional().nullable(),
description: z.string().optional().nullable(),
location: z.string().optional().nullable(),
remote_type: RemoteType.optional().nullable(),
salary_min: z.number().int().nonnegative().optional().nullable(),
salary_max: z.number().int().nonnegative().optional().nullable(),
stack: z.array(z.string()).default([]),
apply_url: z.string().url().optional().nullable(),
});
export type JobIngestDto = z.infer<typeof JobIngestDto>;
export const JobIngestRequest = z.object({
jobs: z.array(JobIngestDto).min(1).max(500),
});
export type JobIngestRequest = z.infer<typeof JobIngestRequest>;
export const JobIngestResponse = z.object({
inserted: z.number().int().nonnegative(),
updated: z.number().int().nonnegative(),
archived: z.number().int().nonnegative(),
});
export type JobIngestResponse = z.infer<typeof JobIngestResponse>;
export const Job = JobIngestDto.extend({
id: z.string().uuid(),
first_seen_at: z.string(),
last_seen_at: z.string(),
archived: z.boolean(),
starred: z.boolean(),
applied_at: z.string().nullable(),
notes: z.string().nullable(),
});
export type Job = z.infer<typeof Job>;
export const JobPatchDto = z.object({
starred: z.boolean().optional(),
archived: z.boolean().optional(),
applied_at: z.string().datetime().nullable().optional(),
notes: z.string().nullable().optional(),
});
export type JobPatchDto = z.infer<typeof JobPatchDto>;
export const JobListQuery = z.object({
archived: z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
.optional(),
remote_type: RemoteType.optional(),
stack: z.array(z.string()).optional(),
since: z.string().datetime().optional(),
starred: z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
.optional(),
});
export type JobListQuery = z.infer<typeof JobListQuery>;
export const JobSearchCriteria = z.object({
id: z.string().uuid(),
name: z.string().nullable(),
titles: z.array(z.string()),
locations: z.array(z.string()),
stack: z.array(z.string()),
remote_types: z.array(RemoteType),
salary_min: z.number().int().nullable(),
active: z.boolean(),
created_at: z.string(),
updated_at: z.string(),
});
export type JobSearchCriteria = z.infer<typeof JobSearchCriteria>;
export const JobSearchCriteriaUpsert = z.object({
name: z.string().optional().nullable(),
titles: z.array(z.string()).default([]),
locations: z.array(z.string()).default([]),
stack: z.array(z.string()).default([]),
remote_types: z.array(RemoteType).default([]),
salary_min: z.number().int().nonnegative().nullable().optional(),
active: z.boolean().default(true),
});
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Phase 5 — AI proposed actions (kept here so PWA + API agree) // Phase 5 — AI proposed actions (kept here so PWA + API agree)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

7623
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff