- Drop @supabase/supabase-js entirely; add drizzle-orm + postgres (porsager) driver - New packages/db: schema (pgSchema ordinarthur_os), client factory, migrate runner, drizzle-kit config - SQL migrations: 0000_init (pgcrypto + schema), 0001_jobs (jobs + job_search_criteria, no RLS) - Rewrite apps/api db module with DI symbols DB/DB_HANDLE + @InjectDb() decorator - Rewrite jobs.service.ts with Drizzle queries (upsert via onConflictDoUpdate, arrayOverlaps for stack filter) - Replace SUPABASE_* env vars with DATABASE_URL in env config + .env.example - Add docker-compose.yml (Postgres 16-alpine, dev only) - Add deploy/k8s/postgres.yaml (StatefulSet + PVC), migrate.job.yaml, updated secrets.template.yaml - Update all docs (README, PLAN, ARCHITECTURE, CLAUDE.md, AGENTS.md, packages/db/README.md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 KiB
ordinarthur-os — Architecture
1. Topologie haut niveau
┌──────────────────────────────┐ ┌──────────────────────────┐
│ PWA (Vite+React) │ HTTPS │ NestJS API (BFF unique) │
│ os.arthurbarre.fr │──────▶ │ api.os.arthurbarre.fr │
│ Installable iOS / offline │ │ │
└──────────────────────────────┘ └─────────────┬────────────┘
│
┌──────────────┬──────────────────┼────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌────────────┐
│ Postgres │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
│ (k3s STS) │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
│ schema │ │ function- │ │ │ │ │ │ │
│ ordinarthur│ │ calling │ │ │ │ │ │ │
│ _os │ │ │ │ │ │ │ │ │
└────────────┘ └─────────────┘ └──────────────┘ └────────────┘ └────────────┘
La PWA ne parle jamais directement à Postgres ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token. Le service Postgres est en ClusterIP interne au k3s, jamais exposé.
2. Monorepo
ordinarthur-os/
├── apps/
│ ├── pwa/ # Vite + React + TanStack Router/Query + Tailwind + shadcn
│ │ ├── public/
│ │ │ ├── manifest.webmanifest
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
│ │ └── src/
│ │ ├── routes/ # / /jobs /todos /projects /agenda /health /settings/*
│ │ ├── components/ # Composants métier
│ │ ├── design/ # Tokens + primitives éditoriales
│ │ ├── api/ # Client HTTP typé, zod parse
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
│ │ ├── lib/ # Utils
│ │ └── main.tsx
│ │
│ └── api/ # NestJS
│ └── src/
│ ├── modules/
│ │ ├── auth/ # BearerGuard, AuthController
│ │ ├── jobs/
│ │ ├── todos/
│ │ ├── projects/
│ │ ├── agenda/
│ │ ├── health/
│ │ ├── ai/ # command, voice, function dispatcher
│ │ ├── telegram/
│ │ └── sync/ # replay client_mutations, dedup
│ ├── db/ # Drizzle client factory (@ordinarthur-os/db)
│ ├── config/ # env schema (zod)
│ └── main.ts
│
├── packages/
│ ├── shared/ # Types + zod DTOs partagés pwa/api
│ └── db/ # Drizzle ORM : schema TS + migrations SQL
│ ├── drizzle.config.ts
│ ├── src/
│ │ ├── schema/ # Définitions tables (pgSchema ordinarthur_os)
│ │ ├── client.ts # createDb(connectionString)
│ │ └── migrate.ts # runner DATABASE_URL-driven
│ └── migrations/ # 0000_init.sql, 0001_jobs.sql, … + meta/_journal.json
│
├── deploy/
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
│ ├── namespace.yaml
│ ├── postgres.yaml # StatefulSet Postgres 16 + PVC
│ ├── api.deployment.yaml
│ ├── pwa.deployment.yaml
│ ├── ingress.yaml
│ ├── migrate.job.yaml # Job one-shot drizzle migrate
│ ├── secrets.template.yaml
│ └── backup.cronjob.yaml
│
├── .gitea/workflows/
│ └── deploy.yml # À aligner sur le skill /deploy d'Arthur
│
├── pnpm-workspace.yaml
├── turbo.json
├── CLAUDE.md # Pointe vers PLAN.md + ARCHITECTURE.md
└── README.md
3. Design system (Tailwind + primitives)
Tokens (tailwind.config.ts)
theme: {
extend: {
colors: {
bg: '#F5F1EA',
ink: '#0F0F0F',
muted: '#8A8579',
accent: '#FF4A1C',
surface: '#FFFFFF',
},
fontFamily: {
sans: ['"Inter Tight"', 'system-ui', 'sans-serif'],
mono: ['"Space Mono"', '"JetBrains Mono"', 'monospace'],
},
letterSpacing: {
tightest: '-0.03em',
label: '0.06em',
},
},
},
Primitives éditoriales
<Label prefix="[ 01 ]">À PROPOS</Label>→ mono, uppercase, 11px, tracking-label, muted<SectionHeader number="01" label="À PROPOS" title="Le titre" />→ grid bordé<GridFrame cols={12} children={...} />→ wrapper avecborder border-inketdivide-ink<DataChip dotColor="accent|ink">REACT</DataChip>→ les cases de stack du portfolio<MetaRow label="RÔLE" value="FULLSTACK · REACT · NODE" />→ clé en muted, valeur en ink<BigHeading>Développeur <em>qui construit</em> des outils.</BigHeading>→ font-sans font-light, clamp responsive, italic = orange
Règles visuelles
- Bordures partout : sections, grid cells, nav
- Pas d'ombre portée (shadow-none)
- Imagery grayscale par défaut
- Italique = accent orange (convention portfolio)
- Motion minimal (fade ≤ 200ms), pas d'animation flashy
accent-pulse: petit dot orange qui pulse doucement pour marquer "live/disponible" (voir header portfolio)
4. Schéma Postgres ordinarthur_os
Source de vérité : les définitions Drizzle dans packages/db/src/schema/. Le SQL ci-dessous est l'équivalent dénormalisé, à titre de référence — les migrations réelles vivent dans packages/db/migrations/.
create schema if not exists ordinarthur_os;
set search_path to ordinarthur_os;
-- ============================================================
-- JOBS
-- ============================================================
create table 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[] default '{}',
apply_url text,
first_seen_at timestamptz default now(),
last_seen_at timestamptz default now(),
archived boolean default false,
starred boolean default false,
applied_at timestamptz,
notes text
);
create index on jobs(last_seen_at desc);
create index on jobs(archived);
create table job_search_criteria (
id uuid primary key default gen_random_uuid(),
name text,
titles text[] default '{}',
locations text[] default '{}',
stack text[] default '{}',
remote_types text[] default '{}',
salary_min int,
active boolean default true,
updated_at timestamptz default now()
);
-- ============================================================
-- PROJECTS
-- ============================================================
create table projects (
id uuid primary key default gen_random_uuid(),
name text not null,
kind text check (kind in ('freelance','perso')) default 'perso',
status text default 'active',
description text,
created_at timestamptz default now()
);
create table project_steps (
id uuid primary key default gen_random_uuid(),
project_id uuid references projects(id) on delete cascade,
title text not null,
description text,
status text default 'backlog'
check (status in ('backlog','todo','doing','review','done')),
position int default 0,
created_at timestamptz default now()
);
create index on project_steps(project_id, status, position);
create table project_ideas (
id uuid primary key default gen_random_uuid(),
project_id uuid references projects(id) on delete cascade,
content text not null,
created_at timestamptz default now()
);
-- ============================================================
-- TODOS
-- ============================================================
create table todos (
id uuid primary key default gen_random_uuid(),
title text not null,
description text, -- markdown
status text default 'inbox'
check (status in ('inbox','todo','doing','done','archived')),
priority smallint check (priority between 0 and 3),
due_at timestamptz,
tags text[] default '{}',
project_id uuid references projects(id) on delete set null,
checklist jsonb default '[]'::jsonb, -- [{text, done}]
energy text check (energy in ('low','med','high')),
context text, -- '@home','@laptop',…
recurrence text, -- rrule string
ticket_url text,
verification_steps text[] default '{}',
ai_enriched boolean default false,
created_at timestamptz default now(),
completed_at timestamptz
);
create index on todos(status);
create index on todos(due_at);
-- ============================================================
-- AGENDA
-- ============================================================
create table calendar_events (
id uuid primary key default gen_random_uuid(),
google_event_id text unique,
title text not null,
description text,
location text,
starts_at timestamptz not null,
ends_at timestamptz not null,
all_day boolean default false,
source text default 'ordinarthur-os', -- 'ordinarthur-os' | 'google'
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create index on calendar_events(starts_at);
create table google_oauth_tokens ( -- single-row pour Arthur
id smallint primary key default 1 check (id = 1),
access_token text not null,
refresh_token text not null,
expires_at timestamptz not null,
calendar_id text -- id du calendar "ordinarthur-os"
);
-- ============================================================
-- HEALTH
-- ============================================================
create table daily_checkins (
day date primary key,
meds_taken boolean default false,
note text,
updated_at timestamptz default now()
);
-- ============================================================
-- AI AUDIT
-- ============================================================
create table ai_actions (
id uuid primary key default gen_random_uuid(),
input_text text,
transcript text, -- si voice
function_name text,
function_args jsonb,
result jsonb,
status text check (status in ('proposed','confirmed','cancelled','failed')),
created_at timestamptz default now()
);
-- ============================================================
-- OFFLINE SYNC
-- ============================================================
create table client_mutations (
client_mutation_id text primary key, -- uuid généré côté PWA
applied_at timestamptz default now(),
result jsonb
);
Pas de RLS. La base n'est jamais exposée : le seul client SQL est le backend NestJS (ClusterIP interne au k3s, credentials Postgres classiques). La PWA est derrière le bearer Nest.
5. API NestJS — routes
Middleware global : BearerGuard sauf /health, /telegram/webhook (signé autrement), /agenda/ical/:secret.ics (secret URL).
GET /health { ok: true, version }
POST /auth/verify
# JOBS
POST /jobs/ingest { jobs: JobIngestDto[] } → { inserted, updated }
GET /jobs ?archived&remote_type&stack[]&since
PATCH /jobs/:id { starred?, archived?, applied_at? }
GET /jobs/criteria
PUT /jobs/criteria/:id
POST /jobs/criteria
DELETE /jobs/criteria/:id
# TODOS
GET /todos
POST /todos
PATCH /todos/:id
DELETE /todos/:id
POST /todos/:id/ai-enrich → renvoie un draft (non sauvé)
POST /todos/:id/ai-enrich/apply { draft } → applique
# PROJECTS
GET /projects
POST /projects
PATCH /projects/:id
DELETE /projects/:id
GET /projects/:id/steps
POST /projects/:id/steps
PATCH /projects/:id/steps/:stepId
DELETE /projects/:id/steps/:stepId
POST /projects/:id/steps/reorder { order: uuid[] }
GET /projects/:id/ideas
POST /projects/:id/ideas
DELETE /projects/:id/ideas/:ideaId
# AGENDA
GET /agenda/events ?from&to
POST /agenda/events
PATCH /agenda/events/:id
DELETE /agenda/events/:id
GET /agenda/google/oauth/start
GET /agenda/google/oauth/callback
POST /agenda/google/sync
GET /agenda/ical/:secret.ics → text/calendar
# HEALTH
GET /health-tab/today { day, meds_taken, note }
POST /health-tab/today/toggle → flips meds_taken, returns row
GET /health-tab/history?days=30
# AI
POST /ai/command { text } → { actions: ProposedAction[] }
POST /ai/voice multipart audio → { transcript, actions }
POST /ai/command/confirm { actions: ProposedAction[] } → { results }
# TELEGRAM
POST /telegram/webhook (header X-Telegram-Bot-Api-Secret-Token)
POST /telegram/set-webhook (admin, one-shot)
# SYNC (offline)
POST /sync/replay { mutations: ClientMutation[] } → { acked, errors }
AI function calling — fonctions exposées à Mistral
type ProposedAction =
| { fn: 'create_todo', args: { title, description?, due_at?, priority?, project_id?, tags? } }
| { fn: 'add_project_idea', args: { project_id, content } }
| { fn: 'add_project_step', args: { project_id, title, status? } }
| { fn: 'create_calendar_event', args: { title, starts_at, ends_at, location?, description? } }
| { fn: 'toggle_daily_checkin', args: { note? } };
Flow garanti : l'API ne jamais exécute une action directement. Elle renvoie un ProposedAction[] à la PWA, qui affiche une modal de confirmation. Seul /ai/command/confirm écrit en DB.
6. PWA — routing & pages
/ Dashboard (events du jour, todos due today, médocs, bouton 🎤, Cmd-K)
/jobs Liste des offres (filtres toutes/remote/hybrid/marseille)
/jobs/:id Détail + actions (star, archive, applied)
/todos Inbox + filtres
/projects Liste
/projects/:id Détail + kanban + idées
/agenda Vue semaine / jour
/health Toggle du jour + heatmap 30j
/settings/jobs Édition des critères (lus par le scheduled task)
/settings/account Bearer token, health checks, connect Google, connect Telegram
Offline
- Service worker (Workbox) : précache le shell, stale-while-revalidate sur GET
/api/* - Dexie tables :
todos_cache,projects_cache,pendingMutations - Toute mutation offline génère un
client_mutation_id(uuid v4) et s'empile danspendingMutations - Au retour online :
POST /sync/replayséquentiel, l'API déduplique viaclient_mutations(idempotence) - UI optimiste via
useMutationTanStack Query (onMutateupdate cache,onErrorrollback)
7. Voice magic button
MediaRecorderdans le navigateur → blob WebM/OpusPOST /ai/voice(multipart) vers NestJS- Nest → Groq Whisper (
whisper-large-v3-turbo, lang=fr) → transcript - Nest → Mistral chat completions avec
tools(les fonctions ci-dessus) + system prompt FR - Mistral renvoie 1..n
tool_calls→ Nest wrappe enProposedAction[], log dansai_actionsavec status=proposed - Réponse API :
{ transcript, actions } - PWA affiche modal : "Tu veux faire : …" avec boutons Confirmer / Annuler / Éditer
- Sur Confirmer →
POST /ai/command/confirm→ Nest exécute + updateai_actions.status='confirmed'
8. Déploiement k3s
Ingress
# deploy/k8s/ingress.yaml (exemple, à aligner sur conf Traefik d'Arthur)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ordinarthur-os
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
tls:
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
secretName: ordinarthur-os-tls
rules:
- host: os.arthurbarre.fr
http:
paths: [{ path: /, pathType: Prefix, backend: { service: { name: pwa, port: { number: 80 } } } }]
- host: api.os.arthurbarre.fr
http:
paths: [{ path: /, pathType: Prefix, backend: { service: { name: api, port: { number: 3000 } } } }]
Secrets k8s attendus
ordinarthur-os-secrets (consommé par l'API) :
API_BEARER_TOKENDATABASE_URL(postgres://<user>:<pwd>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os)MISTRAL_API_KEYGROQ_API_KEYGOOGLE_OAUTH_CLIENT_IDGOOGLE_OAUTH_CLIENT_SECRETGOOGLE_OAUTH_REDIRECT_URI(https://api.os.arthurbarre.fr/agenda/google/oauth/callback)TELEGRAM_BOT_TOKENTELEGRAM_WEBHOOK_SECRETICAL_FEED_SECRET
ordinarthur-os-db-secrets (consommé par le StatefulSet Postgres) :
POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB
ordinarthur-os-backup-secrets (CronJob) :
PGURL(même valeur queDATABASE_URL)RCLONE_REMOTE(ex.b2:ordinarthur-os-backups)
CronJob backup
# deploy/k8s/backup.cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata: { name: ordinarthur-os-backup }
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: pgdump
image: postgres:16-alpine
envFrom: [{ secretRef: { name: ordinarthur-os-secrets } }]
command: ["/bin/sh","-c"]
args:
- |
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
rclone copy /tmp/dump.gz "$RCLONE_REMOTE/$(date +%F).gz"
PGURL= même chaînepostgres://…queDATABASE_URL. Le schéma reste dédié (--schema=ordinarthur_os) pour faciliter d'éventuelles restaurations sélectives.
Pipeline Gitea (à aligner avec le skill /deploy d'Arthur)
# .gitea/workflows/deploy.yml — squelette
on: { push: { branches: [main] } }
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install --frozen-lockfile
- run: pnpm -r build
- name: Build images
run: |
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }} apps/api
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }} apps/pwa
- name: Push
run: |
echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
- name: Deploy
run: |
kubectl set image deploy/api api=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
kubectl set image deploy/pwa pwa=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
9. Sécurité
- TLS everywhere via cert-manager
- Bearer token Nest : stocké seulement dans les secrets k8s, jamais dans le bundle PWA → la PWA demande le token à l'utilisateur (écran d'onboarding) et le stocke dans
localStorage(accès par Arthur uniquement sur son device) - Webhook Telegram : vérification de
X-Telegram-Bot-Api-Secret-Token - Feed iCal : path contient un secret rotable (
ICAL_FEED_SECRET) - Postgres : service ClusterIP interne au k3s, jamais exposé ; credentials via Secret k8s
- Backups : quotidien, chiffrés au repos côté bucket, rétention 30 jours
10. Observabilité (phase ultérieure)
- Logs Nest en JSON → si Arthur a déjà Loki/Grafana, les envoyer là
- Health probes Liveness / Readiness sur
/health - Sentry optionnel plus tard
Annexe — variables d'environnement
apps/api (.env.example)
NODE_ENV=production
PORT=3000
API_BEARER_TOKEN=
DATABASE_URL=postgres://ordinarthur:changeme@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
GROQ_API_KEY=
GROQ_STT_MODEL=whisper-large-v3-turbo
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback
TELEGRAM_BOT_TOKEN=
TELEGRAM_WEBHOOK_SECRET=
ICAL_FEED_SECRET=
apps/pwa (.env.example)
VITE_API_BASE_URL=https://api.os.arthurbarre.fr