ordinarthur-os/ARCHITECTURE.md
ordinarthur 9c93e74318 replace Supabase with Postgres + Drizzle ORM
- 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>
2026-04-16 10:15:34 +02:00

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 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

  • 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 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

# 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

# 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)

# .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