# 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) ```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 - `` → mono, uppercase, 11px, tracking-label, muted - `` → grid bordé - `` → wrapper avec `border border-ink` et `divide-ink` - `REACT ` → les cases de stack du portfolio - `` → clé en muted, valeur en ink - `Développeur qui construit des outils.` → 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. 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. ## 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 ```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. ## 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 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) ## 7. 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'` ## 8. 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://:@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 }} ``` ## 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 ```