ordinarthur-os/ARCHITECTURE.md
2026-04-16 12:46:52 +02:00

607 lines
23 KiB
Markdown

# 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, organisés par domaine
│ │ │ ├── ai/ # MagicButton, VoiceConfirmModal
│ │ │ ├── health/ # MedsSlider (UI pure), MedsSection (data + layout)
│ │ │ ├── jobs/
│ │ │ ├── todos/
│ │ │ └── …
│ │ ├── 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)
```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 avec `border border-ink` et `divide-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
- Le produit reste un outil personnel avant tout : clarté d'usage et vitesse priment sur la démonstration visuelle
- 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. Conventions composants PWA
### Séparation UI / data
Chaque feature suit un découpage strict en deux niveaux :
| Niveau | Fichier | Responsabilité |
| --- | --- | --- |
| **UI pure** | `ComponentName.tsx` | Props uniquement, aucun `useQuery`/`useMutation`, pas d'import `api`. Testable en isolation. |
| **Section data** | `ComponentNameSection.tsx` | Encapsule le `useQuery` + `useMutation` TanStack Query, compose le composant UI, gère l'état de chargement. |
La route (`routes/*.tsx`) ne fait jamais de fetching pour une feature tierce — elle importe la `Section` correspondante et la pose.
**Exemple : médocs**
```
components/health/
MedsSlider.tsx ← drag UI, props: medsTaken / onToggle / disabled
MedsSection.tsx ← query GET /health-tab/today + mutation POST toggle
```
```tsx
// routes/index.tsx — la route ne sait rien de l'API health
import { MedsSection } from "@/components/health/MedsSection";
// …
<MedsSection />
```
### Organisation des dossiers `components/`
Un sous-dossier par domaine métier, aligné sur les modules NestJS :
```
components/
ai/ # MagicButton, VoiceConfirmModal
health/ # MedsSlider, MedsSection
jobs/
todos/
projects/
agenda/
```
Pas de dossier `common/` fourre-tout. Les primitives partagées (bordures, labels, empty states) vivent dans `design/`.
## 5. Schéma Postgres `ordinarthur_os`
Source de vérité : les définitions Drizzle dans [`packages/db/src/schema/`](./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/`.
```sql
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.
## 6. 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
```ts
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.
## 7. 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 dans `pendingMutations`
- Au retour online : `POST /sync/replay` séquentiel, l'API déduplique via `client_mutations` (idempotence)
- UI optimiste via `useMutation` TanStack Query (`onMutate` update cache, `onError` rollback)
## 8. Voice magic button
1. `MediaRecorder` dans le navigateur → blob WebM/Opus
2. `POST /ai/voice` (multipart) vers NestJS
3. Nest → Groq Whisper (`whisper-large-v3-turbo`, lang=fr) → transcript
4. Nest → Mistral chat completions avec `tools` (les fonctions ci-dessus) + system prompt FR
5. Mistral renvoie 1..n `tool_calls` → Nest wrappe en `ProposedAction[]`, log dans `ai_actions` avec status=`proposed`
6. Réponse API : `{ transcript, actions }`
7. PWA affiche modal : "Tu veux faire : …" avec boutons **Confirmer** / **Annuler** / **Éditer**
8. Sur Confirmer → `POST /ai/command/confirm` → Nest exécute + update `ai_actions.status='confirmed'`
## 9. Déploiement k3s
### Ingress
```yaml
# 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_TOKEN`
- `DATABASE_URL` (`postgres://<user>:<pwd>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os`)
- `MISTRAL_API_KEY`
- `GROQ_API_KEY`
- `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`
`ordinarthur-os-db-secrets` (consommé par le StatefulSet Postgres) :
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
`ordinarthur-os-backup-secrets` (CronJob) :
- `PGURL` (même valeur que `DATABASE_URL`)
- `RCLONE_REMOTE` (ex. `b2:ordinarthur-os-backups`)
### CronJob backup
```yaml
# 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îne `postgres://…` que `DATABASE_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)
```yaml
# .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 }}
```
## 10. 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
## 11. 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
```