From bc0c15873f84d5002b4ed40b4013b9ae47673f95 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 15 Apr 2026 16:41:19 +0200 Subject: [PATCH] init --- .gitea/workflows/deploy.yml | 43 ++ .gitignore | 11 + .nvmrc | 1 + ARCHITECTURE.md | 541 ++++++++++++++++++ CLAUDE.md | 29 + PLAN.md | 119 ++++ README.md | 14 + apps/api/.env.example | 26 + apps/api/Dockerfile | 24 + apps/api/nest-cli.json | 8 + apps/api/package.json | 30 + apps/api/src/app.module.ts | 23 + apps/api/src/config/config.module.ts | 16 + apps/api/src/config/env.ts | 42 ++ apps/api/src/db/db.module.ts | 27 + apps/api/src/main.ts | 17 + apps/api/src/modules/auth/auth.controller.ts | 14 + apps/api/src/modules/auth/auth.module.ts | 10 + .../api/src/modules/auth/bearer.middleware.ts | 21 + .../src/modules/health/health.controller.ts | 15 + apps/api/src/modules/health/health.module.ts | 7 + apps/api/tsconfig.json | 15 + apps/pwa/.env.example | 1 + apps/pwa/Dockerfile | 15 + apps/pwa/index.html | 16 + apps/pwa/nginx.conf | 25 + apps/pwa/package.json | 34 ++ apps/pwa/postcss.config.cjs | 6 + apps/pwa/public/.gitkeep | 0 apps/pwa/public/icons/README.md | 10 + apps/pwa/src/api/client.ts | 47 ++ apps/pwa/src/design/AccentDot.tsx | 13 + apps/pwa/src/design/BigHeading.tsx | 25 + apps/pwa/src/design/DataChip.tsx | 25 + apps/pwa/src/design/GridFrame.tsx | 20 + apps/pwa/src/design/Label.tsx | 24 + apps/pwa/src/design/MetaRow.tsx | 17 + apps/pwa/src/design/SectionHeader.tsx | 29 + apps/pwa/src/design/index.ts | 7 + apps/pwa/src/lib/cn.ts | 6 + apps/pwa/src/main.tsx | 26 + apps/pwa/src/routeTree.gen.ts | 26 + apps/pwa/src/routes/__root.tsx | 46 ++ apps/pwa/src/routes/index.tsx | 72 +++ apps/pwa/src/styles.css | 16 + apps/pwa/tailwind.config.ts | 34 ++ apps/pwa/tsconfig.json | 16 + apps/pwa/vite.config.ts | 38 ++ deploy/k8s/README.md | 24 + deploy/k8s/api.deployment.yaml | 41 ++ deploy/k8s/backup.cronjob.yaml | 29 + deploy/k8s/ingress.yaml | 27 + deploy/k8s/namespace.yaml | 4 + deploy/k8s/pwa.deployment.yaml | 31 + deploy/k8s/secrets.template.yaml | 24 + package.json | 19 + packages/db/README.md | 15 + packages/db/migrations/0001_schema.sql | 20 + packages/db/package.json | 8 + packages/shared/package.json | 20 + packages/shared/src/index.ts | 74 +++ packages/shared/tsconfig.json | 9 + pnpm-workspace.yaml | 3 + tsconfig.base.json | 17 + turbo.json | 17 + 65 files changed, 2029 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/config/config.module.ts create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/db/db.module.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/auth/auth.controller.ts create mode 100644 apps/api/src/modules/auth/auth.module.ts create mode 100644 apps/api/src/modules/auth/bearer.middleware.ts create mode 100644 apps/api/src/modules/health/health.controller.ts create mode 100644 apps/api/src/modules/health/health.module.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/pwa/.env.example create mode 100644 apps/pwa/Dockerfile create mode 100644 apps/pwa/index.html create mode 100644 apps/pwa/nginx.conf create mode 100644 apps/pwa/package.json create mode 100644 apps/pwa/postcss.config.cjs create mode 100644 apps/pwa/public/.gitkeep create mode 100644 apps/pwa/public/icons/README.md create mode 100644 apps/pwa/src/api/client.ts create mode 100644 apps/pwa/src/design/AccentDot.tsx create mode 100644 apps/pwa/src/design/BigHeading.tsx create mode 100644 apps/pwa/src/design/DataChip.tsx create mode 100644 apps/pwa/src/design/GridFrame.tsx create mode 100644 apps/pwa/src/design/Label.tsx create mode 100644 apps/pwa/src/design/MetaRow.tsx create mode 100644 apps/pwa/src/design/SectionHeader.tsx create mode 100644 apps/pwa/src/design/index.ts create mode 100644 apps/pwa/src/lib/cn.ts create mode 100644 apps/pwa/src/main.tsx create mode 100644 apps/pwa/src/routeTree.gen.ts create mode 100644 apps/pwa/src/routes/__root.tsx create mode 100644 apps/pwa/src/routes/index.tsx create mode 100644 apps/pwa/src/styles.css create mode 100644 apps/pwa/tailwind.config.ts create mode 100644 apps/pwa/tsconfig.json create mode 100644 apps/pwa/vite.config.ts create mode 100644 deploy/k8s/README.md create mode 100644 deploy/k8s/api.deployment.yaml create mode 100644 deploy/k8s/backup.cronjob.yaml create mode 100644 deploy/k8s/ingress.yaml create mode 100644 deploy/k8s/namespace.yaml create mode 100644 deploy/k8s/pwa.deployment.yaml create mode 100644 deploy/k8s/secrets.template.yaml create mode 100644 package.json create mode 100644 packages/db/README.md create mode 100644 packages/db/migrations/0001_schema.sql create mode 100644 packages/db/package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 turbo.json diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e04606d --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,43 @@ +# Squelette à aligner sur le skill /deploy d'Arthur. +# - Build images api + pwa +# - Push vers Gitea Container Registry +# - kubectl set image (ou apply via kustomize plus tard) +on: + push: + branches: [main] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: { version: 9 } + - uses: actions/setup-node@v4 + with: { node-version: 20, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm -r build + + - name: Login Gitea Container Registry + run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin + + - name: Build & push images + run: | + API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }} + PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }} + docker build -f apps/api/Dockerfile -t "$API_TAG" . + docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" . + docker push "$API_TAG" + docker push "$PWA_TAG" + echo "API_TAG=$API_TAG" >> $GITHUB_ENV + echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV + + - name: Deploy on k3s + env: + KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }} + run: | + mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config + kubectl -n ordinarthur-os set image deploy/api api=$API_TAG + kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG + kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s + kubectl -n ordinarthur-os rollout status deploy/pwa --timeout=120s diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e815d90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +dist +.turbo +.vite +.next +.env +.env.* +!.env.example +*.log +.DS_Store +coverage diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..42637ff --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,541 @@ +# 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 │ │ │ + └──────────────────────────────┘ └─────────────┬────────────┘ + │ + ┌──────────────┬──────────────────┼────────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌────────────┐ + │ Supabase │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │ + │ self-host │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │ + │ schema │ │ function- │ │ │ │ │ │ │ + │ ordinarthur│ │ calling │ │ │ │ │ │ │ + │ _os │ │ │ │ │ │ │ │ │ + └────────────┘ └─────────────┘ └──────────────┘ └────────────┘ └────────────┘ +``` + +La PWA ne parle **jamais** directement à Supabase ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token. + +## 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/ # supabase client factory +│ ├── config/ # env schema (zod) +│ └── main.ts +│ +├── packages/ +│ ├── shared/ # Types + zod DTOs partagés pwa/api +│ └── db/ +│ └── migrations/ # 0001_schema.sql, 0002_jobs.sql, … +│ +├── deploy/ +│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur +│ ├── api.deployment.yaml +│ ├── pwa.deployment.yaml +│ ├── ingress.yaml +│ ├── 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 + +- `À PROPOS` → 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 + +- 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` + +```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 +); +``` + +**RLS** : activée partout avec policy unique `using (auth.role() = 'service_role')`. Seul le Nest (avec la service key) peut lire/écrire. 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` : +- `API_BEARER_TOKEN` +- `SUPABASE_URL` (`https://supabase.arthurbarre.fr`) +- `SUPABASE_SERVICE_ROLE_KEY` +- `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` + +### 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 + # upload vers S3/B2 via rclone (à confirmer avec Arthur) + rclone copy /tmp/dump.gz remote:ordinarthur-os-backups/$(date +%F).gz +``` + +### 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`) +- Supabase : schema dédié + RLS service-role-only +- 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= +SUPABASE_URL=https://supabase.arthurbarre.fr +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_SCHEMA=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 +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6fc219e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,29 @@ +# CLAUDE.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` diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8798669 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,119 @@ +# ordinarthur-os — Plan d'implémentation + +> **Status** : planning terminé 2026-04-15. À implémenter via Claude Code (Sonnet). + +## Vision produit + +Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabiliser**. + +- Dashboard clair de ce qu'il fait / veut faire +- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic +- Pas de "weekly review" automatique, pas de nudges +- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → transcription → création d'une todo / idée / étape projet / événement agenda, avec validation explicite avant écriture en base + +## Principes directeurs + +1. **Self-hosted, open-source**. Pas de Vercel, pas de Next.js. Tout tourne sur le k3s d'Arthur. +2. **Single-user**. Pas de multi-tenant, pas d'invitations, pas de partage. Bearer token unique pour protéger l'API. +3. **PWA installable iOS**. Vite + React, pas de SSR. Service worker + mutation queue pour l'offline. +4. **BFF unique**. La PWA ne parle qu'au NestJS. Le Nest parle à Supabase, Mistral, Groq, Google Calendar, Telegram. +5. **Design éditorial / Swiss-brutalist** — mirror du portfolio arthurbarre.fr (cream, ink, orange, borders, mono labels). + +## Stack verrouillée + +| Couche | Choix | +| --- | --- | +| Monorepo | pnpm workspaces + Turborepo | +| Frontend | Vite + React 18 + TanStack Router + TanStack Query + Tailwind + shadcn/ui | +| Backend | NestJS + `@supabase/supabase-js` (pas d'ORM) | +| DB | Postgres via Supabase self-hosted, schéma dédié `ordinarthur_os` | +| Auth | Bearer token statique (single-user), middleware Nest | +| IA LLM | Mistral `mistral-small-latest` (low-cost) via API | +| STT | Groq `whisper-large-v3-turbo` | +| Bot | Telegram Bot API (webhook) | +| Calendar | Google Calendar API (Apple souscrit au calendar Google via webcal) | +| Langue IA | FR only | +| Déploiement | Images Docker → Gitea Container Registry → pipeline Gitea Actions → k3s (Traefik + cert-manager supposés présents) | +| Backups | CronJob k8s `pg_dump --schema=ordinarthur_os` quotidien → stockage S3-compatible (à confirmer avec Arthur) | + +## Roadmap — ordre d'implémentation + +### Phase 0 — Scaffold (prio immédiate) +- Monorepo `pnpm-workspace.yaml`, `turbo.json` +- `apps/pwa` : Vite + React + Tailwind + shadcn + manifest PWA + service worker placeholder + routing TanStack +- `apps/api` : NestJS + module `health` + middleware bearer + client supabase initialisé +- `packages/shared` : types et zod schemas partagés +- `packages/db` : premier fichier de migration SQL créant le schéma `ordinarthur_os` +- `deploy/k8s` : manifests génériques (à adapter ensuite à la conf Gitea/Traefik d'Arthur) +- Design system : composants primitifs (``, ``, ``, ``, ``) qui reproduisent le style arthurbarre.fr +- Routes `GET /health` et `POST /auth/verify` + +### Phase 1 — Jobs (prio haute, remontée) +- Migration : tables `jobs`, `job_search_criteria` +- API : `POST /jobs/ingest` (bearer), `GET /jobs`, `PATCH /jobs/:id`, `GET/PUT /jobs/criteria` +- PWA : route `/jobs` avec filtres (toutes / remote / hybrid / marseille), rendu éditorial façon portfolio (lignes tableau, pas des cards violettes) +- PWA : route `/settings/jobs` pour éditer les critères (titres, localisations, stack[], remote_types[], salary_min, active) +- Dedup : clé unique `source_url`, update `last_seen_at` si déjà vu +- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`) +- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h + +### Phase 2 — Todos riches +- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet) +- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation) +- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline +- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations` + +### Phase 3 — Projets + Kanban +- Migrations : `projects`, `project_steps`, `project_ideas` +- API : CRUD projets, CRUD steps, reorder, CRUD ideas +- PWA : route `/projects`, détail projet avec kanban (colonnes backlog/todo/doing/review/done), zone idées + +### Phase 4 — Agenda + Google Calendar sync +- Migration : `calendar_events` +- API : OAuth Google (scope calendar), CRUD events avec sync bi-directionnelle, endpoint `/agenda/ical/:secret.ics` pour souscription Apple +- PWA : route `/agenda` vue semaine + jour + +### Phase 5 — IA : bouton texte + voice magic button +- Migration : `ai_actions` (log d'audit) +- API : `POST /ai/command` (texte → function calling Mistral → plan), `POST /ai/voice` (audio → Groq Whisper → texte → Mistral → plan), `POST /ai/command/confirm` (applique après clic user) +- Fonctions exposées au LLM : `create_todo`, `add_project_idea`, `add_project_step`, `create_calendar_event`, `toggle_daily_checkin` +- PWA : bouton "🎤 Parler" sur le dashboard, `Cmd-K` pour la barre de commande texte, modal de confirmation avant exécution + +### Phase 6 — Telegram bot +- Webhook Nest `/telegram/webhook` signé (header `X-Telegram-Bot-Api-Secret-Token`) +- Commandes : `/today` (events + todos du jour), `/todo ` (crée une todo), messages vocaux traités comme le voice magic button +- Rappel quotidien optionnel à une heure configurable (simple message, pas d'action auto) + +### Phase 7 — Health tab +- Migration : `daily_checkins (day date PK, meds_taken boolean, note text?)` +- API : `GET /health/today`, `POST /health/today/toggle` +- PWA : slider/toggle simple "médocs pris aujourd'hui" + historique 30j minimaliste + +### Phase 8 — Finance (reporté) +- Revolut perso n'a pas d'API → passer par GoCardless Bank Account Data (ex-Nordigen) +- À traiter uniquement quand les phases 0–7 sont stables + +## Handoff Claude Code + +Pour reprendre ce projet avec Claude Code (Sonnet) : + +1. Lire `README.md`, `PLAN.md`, `ARCHITECTURE.md` dans cet ordre +2. Pointer `CLAUDE.md` du repo vers ces docs +3. Respecter les règles de collaboration d'Arthur : + - Pas de Next.js, pas de Vercel + - L'IA ne mute jamais la DB sans clic de confirmation + - Design = portfolio arthurbarre.fr (pas le violet/cyan du HTML jobs) +4. Avant de scaffolder, récupérer de l'utilisateur : + - Le dossier `/Users/arthurbarre/dev/perso/proxmox` (conf k3s) pour aligner les manifests + - Le skill `/deploy` ou `/create-deployment` qu'Arthur utilise pour ses autres déploiements Gitea + - Les secrets nécessaires : `MISTRAL_API_KEY`, `GROQ_API_KEY`, `TELEGRAM_BOT_TOKEN`, `GOOGLE_OAUTH_CLIENT_ID/SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `API_BEARER_TOKEN` + - Le choix du stockage backup S3-compatible (B2 / Scaleway / autre) +5. Attaquer par la Phase 0 (scaffold), puis Phase 1 (jobs) — c'est explicitement prioritaire dans la tête d'Arthur. + +## Points ouverts à trancher avec Arthur + +- Stockage backups (quel bucket S3-compatible ?) +- Gitea Container Registry vs GHCR (défaut proposé : Gitea CR, déjà présent dans sa stack) +- STT Groq → clé à créer côté Arthur +- Google OAuth → app Google Cloud à créer, redirect URI `https://api.os.arthurbarre.fr/agenda/google/oauth/callback` +- Bot Telegram → @BotFather → récupérer le token, configurer le webhook vers `https://api.os.arthurbarre.fr/telegram/webhook` diff --git a/README.md b/README.md new file mode 100644 index 0000000..afe3233 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# ordinarthur-os + +Assistant personnel self-hosted d'Arthur Barré. PWA installable + backend NestJS, déployés sur k3s personnel, branchés sur Supabase self-hosted (`supabase.arthurbarre.fr`). + +**But** : aider Arthur à être rigoureux (todos, projets, agenda, recherche d'emploi, santé) **sans le déresponsabiliser**. Toutes les actions IA passent par une confirmation explicite. + +## Documents + +- [`PLAN.md`](./PLAN.md) — roadmap, phases, ordre des tâches. +- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — stack, schéma Postgres, routes API, PWA, design system, déploiement. + +## Handoff + +Ces docs ont été produits en session de planning. L'implémentation est ensuite reprise par Claude Code (Sonnet) sur la machine locale d'Arthur. Voir la section "Handoff Claude Code" dans `PLAN.md`. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..20fa923 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,26 @@ +NODE_ENV=development +PORT=3000 + +# Single-user bearer (génère via `openssl rand -hex 32`) +API_BEARER_TOKEN= + +# Supabase self-hosted +SUPABASE_URL=https://supabase.arthurbarre.fr +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_SCHEMA=ordinarthur_os + +# Phase 5+ +MISTRAL_API_KEY= +MISTRAL_MODEL=mistral-small-latest +GROQ_API_KEY= +GROQ_STT_MODEL=whisper-large-v3-turbo + +# Phase 4 +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback +ICAL_FEED_SECRET= + +# Phase 6 +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..979ac6e --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,24 @@ +# Multi-stage build : build avec pnpm puis runtime minimal +FROM node:20-alpine AS deps +RUN corepack enable +WORKDIR /repo +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/api/package.json apps/api/ +COPY packages/shared/package.json packages/shared/ +COPY packages/db/package.json packages/db/ +RUN pnpm install --frozen-lockfile || pnpm install + +FROM deps AS build +COPY . . +RUN pnpm --filter @ordinarthur-os/api build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable +COPY --from=build /repo/apps/api/dist ./dist +COPY --from=build /repo/apps/api/package.json ./package.json +COPY --from=build /repo/node_modules ./node_modules +COPY --from=build /repo/packages ./packages +EXPOSE 3000 +CMD ["node", "dist/main.js"] diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..4159c09 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@ordinarthur-os/api", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main.js", + "lint": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.4", + "@nestjs/core": "^10.4.4", + "@nestjs/platform-express": "^10.4.4", + "@ordinarthur-os/shared": "workspace:*", + "@supabase/supabase-js": "^2.45.4", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.5", + "@nestjs/schematics": "^10.1.4", + "@types/express": "^4.17.21", + "@types/node": "^20.16.10", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..181ac56 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,23 @@ +import { Module, MiddlewareConsumer, NestModule, RequestMethod } from "@nestjs/common"; +import { ConfigModule } from "./config/config.module"; +import { DbModule } from "./db/db.module"; +import { HealthModule } from "./modules/health/health.module"; +import { AuthModule } from "./modules/auth/auth.module"; +import { BearerMiddleware } from "./modules/auth/bearer.middleware"; + +@Module({ + imports: [ConfigModule, DbModule, HealthModule, AuthModule], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(BearerMiddleware) + .exclude( + { path: "health", method: RequestMethod.GET }, + // Endpoints publics (signés autrement) ajoutés en Phase 4/6 : + // { path: "telegram/webhook", method: RequestMethod.POST }, + // { path: "agenda/ical/:secret.ics", method: RequestMethod.GET }, + ) + .forRoutes("*"); + } +} diff --git a/apps/api/src/config/config.module.ts b/apps/api/src/config/config.module.ts new file mode 100644 index 0000000..3e81f30 --- /dev/null +++ b/apps/api/src/config/config.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from "@nestjs/common"; +import { loadConfig } from "./env"; + +export const APP_CONFIG = Symbol("APP_CONFIG"); + +@Global() +@Module({ + providers: [ + { + provide: APP_CONFIG, + useFactory: () => loadConfig(), + }, + ], + exports: [APP_CONFIG], +}) +export class ConfigModule {} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..eb09da5 --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +const EnvSchema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + PORT: z.coerce.number().int().positive().default(3000), + + API_BEARER_TOKEN: z.string().min(16, "API_BEARER_TOKEN must be at least 16 chars"), + + SUPABASE_URL: z.string().url(), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + SUPABASE_SCHEMA: z.string().default("ordinarthur_os"), + + // Phase 5+ — optionnels jusque-là + MISTRAL_API_KEY: z.string().optional(), + MISTRAL_MODEL: z.string().default("mistral-small-latest"), + GROQ_API_KEY: z.string().optional(), + GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"), + + GOOGLE_OAUTH_CLIENT_ID: z.string().optional(), + GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(), + GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(), + ICAL_FEED_SECRET: z.string().optional(), + + TELEGRAM_BOT_TOKEN: z.string().optional(), + TELEGRAM_WEBHOOK_SECRET: z.string().optional(), +}); + +export type AppConfig = z.infer; + +let cached: AppConfig | null = null; + +export function loadConfig(): AppConfig { + if (cached) return cached; + const parsed = EnvSchema.safeParse(process.env); + if (!parsed.success) { + // eslint-disable-next-line no-console + console.error("[config] invalid env:", parsed.error.flatten().fieldErrors); + throw new Error("Invalid environment configuration"); + } + cached = parsed.data; + return cached; +} diff --git a/apps/api/src/db/db.module.ts b/apps/api/src/db/db.module.ts new file mode 100644 index 0000000..bfea3ad --- /dev/null +++ b/apps/api/src/db/db.module.ts @@ -0,0 +1,27 @@ +import { Global, Module, Inject } from "@nestjs/common"; +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { APP_CONFIG } from "../config/config.module"; +import type { AppConfig } from "../config/env"; + +export const SUPABASE = Symbol("SUPABASE"); +export type Supabase = SupabaseClient; + +@Global() +@Module({ + providers: [ + { + provide: SUPABASE, + inject: [APP_CONFIG], + useFactory: (config: AppConfig): SupabaseClient => + createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + db: { schema: config.SUPABASE_SCHEMA }, + }), + }, + ], + exports: [SUPABASE], +}) +export class DbModule {} + +// Convenience decorator : `constructor(@InjectSupabase() private db: Supabase) {}` +export const InjectSupabase = () => Inject(SUPABASE); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..fff480c --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,17 @@ +import "reflect-metadata"; +import { NestFactory } from "@nestjs/core"; +import { Logger } from "@nestjs/common"; +import { AppModule } from "./app.module"; +import { loadConfig } from "./config/env"; + +async function bootstrap() { + const config = loadConfig(); + const app = await NestFactory.create(AppModule, { + logger: ["log", "warn", "error"], + }); + app.enableCors({ origin: true, credentials: false }); + await app.listen(config.PORT); + Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap"); +} + +bootstrap(); diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..2e25a72 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Post, HttpCode } from "@nestjs/common"; + +@Controller("auth") +export class AuthController { + /** + * Le bearer middleware tourne déjà avant cette route. + * Si on arrive ici, le token est valide. + */ + @Post("verify") + @HttpCode(200) + verify() { + return { ok: true as const }; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..71efe0f --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { BearerMiddleware } from "./bearer.middleware"; +import { AuthController } from "./auth.controller"; + +@Module({ + providers: [BearerMiddleware], + controllers: [AuthController], + exports: [BearerMiddleware], +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/bearer.middleware.ts b/apps/api/src/modules/auth/bearer.middleware.ts new file mode 100644 index 0000000..c332093 --- /dev/null +++ b/apps/api/src/modules/auth/bearer.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common"; +import type { Request, Response, NextFunction } from "express"; +import { APP_CONFIG } from "../../config/config.module"; +import type { AppConfig } from "../../config/env"; + +@Injectable() +export class BearerMiddleware implements NestMiddleware { + constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + + use(req: Request, _res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) { + throw new UnauthorizedException("Missing bearer token"); + } + const token = header.slice("Bearer ".length).trim(); + if (token !== this.config.API_BEARER_TOKEN) { + throw new UnauthorizedException("Invalid bearer token"); + } + next(); + } +} diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..5410519 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get } from "@nestjs/common"; + +@Controller("health") +export class HealthController { + private readonly bootedAt = Date.now(); + + @Get() + health() { + return { + ok: true as const, + version: process.env.npm_package_version ?? "0.0.0", + uptime: Math.floor((Date.now() - this.bootedAt) / 1000), + }; + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..40b7bdf --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { HealthController } from "./health.controller"; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..43e2575 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "outDir": "dist", + "rootDir": "src", + "verbatimModuleSyntax": false, + "isolatedModules": false + }, + "include": ["src"] +} diff --git a/apps/pwa/.env.example b/apps/pwa/.env.example new file mode 100644 index 0000000..99cbcd5 --- /dev/null +++ b/apps/pwa/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3000 diff --git a/apps/pwa/Dockerfile b/apps/pwa/Dockerfile new file mode 100644 index 0000000..b552065 --- /dev/null +++ b/apps/pwa/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS build +RUN corepack enable +WORKDIR /repo +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ +COPY apps/pwa/package.json apps/pwa/ +COPY packages/shared/package.json packages/shared/ +COPY packages/db/package.json packages/db/ +RUN pnpm install --frozen-lockfile || pnpm install +COPY . . +RUN pnpm --filter @ordinarthur-os/pwa build + +FROM nginx:1.27-alpine AS runtime +COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html +COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/apps/pwa/index.html b/apps/pwa/index.html new file mode 100644 index 0000000..b33305b --- /dev/null +++ b/apps/pwa/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + ordinarthur-os + + + + + + diff --git a/apps/pwa/nginx.conf b/apps/pwa/nginx.conf new file mode 100644 index 0000000..bc66a0a --- /dev/null +++ b/apps/pwa/nginx.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Service worker : jamais en cache long + location = /sw.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + location = /manifest.webmanifest { + add_header Cache-Control "no-cache"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Assets immuables (Vite hash dans le nom) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/pwa/package.json b/apps/pwa/package.json new file mode 100644 index 0000000..33335b8 --- /dev/null +++ b/apps/pwa/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ordinarthur-os/pwa", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5173 --host", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,tsx}\"" + }, + "dependencies": { + "@ordinarthur-os/shared": "workspace:*", + "@tanstack/react-query": "^5.59.0", + "@tanstack/react-router": "^1.58.7", + "clsx": "^2.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.2" + }, + "devDependencies": { + "@tanstack/router-vite-plugin": "^1.58.7", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.13", + "typescript": "^5.5.4", + "vite": "^5.4.8", + "vite-plugin-pwa": "^0.20.5" + } +} diff --git a/apps/pwa/postcss.config.cjs b/apps/pwa/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/pwa/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/pwa/public/.gitkeep b/apps/pwa/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/pwa/public/icons/README.md b/apps/pwa/public/icons/README.md new file mode 100644 index 0000000..0f1d9d8 --- /dev/null +++ b/apps/pwa/public/icons/README.md @@ -0,0 +1,10 @@ +# Icons à fournir + +Placer ici (formats PNG) : + +- `icon-192.png` — 192×192 +- `icon-512.png` — 512×512 +- `icon-maskable.png` — 512×512, safe area centrée +- `apple-touch-icon.png` — 180×180 + +Référencés depuis `vite.config.ts` (manifest PWA) et `index.html`. diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts new file mode 100644 index 0000000..4b95bb9 --- /dev/null +++ b/apps/pwa/src/api/client.ts @@ -0,0 +1,47 @@ +/** + * Client HTTP minimal vers le BFF NestJS. + * Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding). + */ + +const BASE = import.meta.env.VITE_API_BASE_URL ?? ""; +const TOKEN_KEY = "ordinarthur.bearer"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +export async function api( + path: string, + init: RequestInit & { auth?: boolean } = {}, +): Promise { + const { auth = true, headers, ...rest } = init; + const finalHeaders: Record = { + "Content-Type": "application/json", + ...(headers as Record | undefined), + }; + if (auth) { + const token = getToken(); + if (token) finalHeaders.Authorization = `Bearer ${token}`; + } + const res = await fetch(`${BASE}${path}`, { ...rest, headers: finalHeaders }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new ApiError(res.status, text || res.statusText); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} diff --git a/apps/pwa/src/design/AccentDot.tsx b/apps/pwa/src/design/AccentDot.tsx new file mode 100644 index 0000000..febeeb2 --- /dev/null +++ b/apps/pwa/src/design/AccentDot.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/cn"; + +/** Point orange qui pulse — marqueur "live/disponible". */ +export function AccentDot({ className }: { className?: string }) { + return ( + + ); +} diff --git a/apps/pwa/src/design/BigHeading.tsx b/apps/pwa/src/design/BigHeading.tsx new file mode 100644 index 0000000..12830d9 --- /dev/null +++ b/apps/pwa/src/design/BigHeading.tsx @@ -0,0 +1,25 @@ +import { cn } from "@/lib/cn"; + +interface BigHeadingProps extends React.HTMLAttributes { + as?: "h1" | "h2"; +} + +/** + * Gros titre éditorial. Italique = orange (convention portfolio). + * Wrap n'importe quel texte en pour qu'il passe en accent. + */ +export function BigHeading({ as: Tag = "h1", className, children, ...props }: BigHeadingProps) { + return ( + + {children} + + ); +} diff --git a/apps/pwa/src/design/DataChip.tsx b/apps/pwa/src/design/DataChip.tsx new file mode 100644 index 0000000..e4c0fa2 --- /dev/null +++ b/apps/pwa/src/design/DataChip.tsx @@ -0,0 +1,25 @@ +import { cn } from "@/lib/cn"; + +interface DataChipProps extends React.HTMLAttributes { + dotColor?: "accent" | "ink"; +} + +export function DataChip({ dotColor = "ink", className, children, ...props }: DataChipProps) { + return ( + + + {children} + + ); +} diff --git a/apps/pwa/src/design/GridFrame.tsx b/apps/pwa/src/design/GridFrame.tsx new file mode 100644 index 0000000..ec56e91 --- /dev/null +++ b/apps/pwa/src/design/GridFrame.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/cn"; + +interface GridFrameProps extends React.HTMLAttributes { + cols?: number; +} + +export function GridFrame({ cols = 12, className, children, ...props }: GridFrameProps) { + return ( + + {children} + + ); +} diff --git a/apps/pwa/src/design/Label.tsx b/apps/pwa/src/design/Label.tsx new file mode 100644 index 0000000..5101915 --- /dev/null +++ b/apps/pwa/src/design/Label.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/lib/cn"; + +interface LabelProps extends React.HTMLAttributes { + prefix?: string; +} + +/** + * Mini-label éditorial : mono, uppercase, 11px, tracking-label, muted. + * Ex: À PROPOS + */ +export function Label({ prefix, className, children, ...props }: LabelProps) { + return ( + + {prefix ? {prefix} : null} + {children} + + ); +} diff --git a/apps/pwa/src/design/MetaRow.tsx b/apps/pwa/src/design/MetaRow.tsx new file mode 100644 index 0000000..e973f25 --- /dev/null +++ b/apps/pwa/src/design/MetaRow.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/cn"; +import { Label } from "./Label"; + +interface MetaRowProps { + label: string; + value: React.ReactNode; + className?: string; +} + +export function MetaRow({ label, value, className }: MetaRowProps) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/pwa/src/design/SectionHeader.tsx b/apps/pwa/src/design/SectionHeader.tsx new file mode 100644 index 0000000..beb1b96 --- /dev/null +++ b/apps/pwa/src/design/SectionHeader.tsx @@ -0,0 +1,29 @@ +import { Label } from "./Label"; +import { cn } from "@/lib/cn"; + +interface SectionHeaderProps { + number: string; + label: string; + title: React.ReactNode; + className?: string; +} + +export function SectionHeader({ number, label, title, className }: SectionHeaderProps) { + return ( + + + {label} + + + + {title} + + + + ); +} diff --git a/apps/pwa/src/design/index.ts b/apps/pwa/src/design/index.ts new file mode 100644 index 0000000..6005927 --- /dev/null +++ b/apps/pwa/src/design/index.ts @@ -0,0 +1,7 @@ +export { Label } from "./Label"; +export { SectionHeader } from "./SectionHeader"; +export { GridFrame } from "./GridFrame"; +export { DataChip } from "./DataChip"; +export { MetaRow } from "./MetaRow"; +export { BigHeading } from "./BigHeading"; +export { AccentDot } from "./AccentDot"; diff --git a/apps/pwa/src/lib/cn.ts b/apps/pwa/src/lib/cn.ts new file mode 100644 index 0000000..cd493ac --- /dev/null +++ b/apps/pwa/src/lib/cn.ts @@ -0,0 +1,6 @@ +import clsx, { type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/pwa/src/main.tsx b/apps/pwa/src/main.tsx new file mode 100644 index 0000000..56c3192 --- /dev/null +++ b/apps/pwa/src/main.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; +import "./styles.css"; + +const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1 } }, +}); + +const router = createRouter({ routeTree, defaultPreload: "intent" }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/apps/pwa/src/routeTree.gen.ts b/apps/pwa/src/routeTree.gen.ts new file mode 100644 index 0000000..7d1b6c0 --- /dev/null +++ b/apps/pwa/src/routeTree.gen.ts @@ -0,0 +1,26 @@ +/* prettier-ignore-start */ +/* eslint-disable */ +// @ts-nocheck + +// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`. +// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run. + +import { Route as rootRoute } from "./routes/__root"; +import { Route as IndexRoute } from "./routes/index"; + +const IndexRouteWithParent = IndexRoute.update({ + path: "/", + getParentRoute: () => rootRoute, +} as any); + +export const routeTree = rootRoute.addChildren({ + IndexRoute: IndexRouteWithParent, +} as any); + +declare module "@tanstack/react-router" { + interface FileRoutesByPath { + "/": { parentRoute: typeof rootRoute }; + } +} + +/* prettier-ignore-end */ diff --git a/apps/pwa/src/routes/__root.tsx b/apps/pwa/src/routes/__root.tsx new file mode 100644 index 0000000..dbcd006 --- /dev/null +++ b/apps/pwa/src/routes/__root.tsx @@ -0,0 +1,46 @@ +import { Outlet, createRootRoute, Link } from "@tanstack/react-router"; +import { AccentDot, Label } from "@/design"; + +export const Route = createRootRoute({ + component: RootLayout, +}); + +function RootLayout() { + return ( + + + + + + ORDINARTHUR-OS + + + {/* Routes activées au fur et à mesure des phases */} + Dashboard + + + + + + + + + ); +} + +function NavLink({ to, children }: { to: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/pwa/src/routes/index.tsx b/apps/pwa/src/routes/index.tsx new file mode 100644 index 0000000..1b47189 --- /dev/null +++ b/apps/pwa/src/routes/index.tsx @@ -0,0 +1,72 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/api/client"; +import { BigHeading, DataChip, GridFrame, Label, MetaRow, SectionHeader } from "@/design"; + +export const Route = createFileRoute("/")({ component: Dashboard }); + +function Dashboard() { + const health = useQuery({ + queryKey: ["health"], + queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }), + refetchInterval: 30_000, + }); + + return ( + + + PHASE 0 · SCAFFOLD + + Un assistant qui n'agit jamais sans ton clic. + + + + + + + + API HEALTH + + + {health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"} + + {health.data && ( + + v{health.data.version} · uptime {health.data.uptime}s + + )} + + + + STACK + + VITE · REACT + NESTJS + SUPABASE + K3S + + + + + + + ROADMAP + + + + + + + + + + + + + + ); +} diff --git a/apps/pwa/src/styles.css b/apps/pwa/src/styles.css new file mode 100644 index 0000000..efe0baf --- /dev/null +++ b/apps/pwa/src/styles.css @@ -0,0 +1,16 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Polices : à servir self-hosted plus tard. Fallback system stack pour l'instant. */ + +@layer base { + html, body, #root { height: 100%; } + body { + background-color: theme(colors.bg); + color: theme(colors.ink); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + *::selection { background: theme(colors.accent); color: theme(colors.bg); } +} diff --git a/apps/pwa/tailwind.config.ts b/apps/pwa/tailwind.config.ts new file mode 100644 index 0000000..a45d658 --- /dev/null +++ b/apps/pwa/tailwind.config.ts @@ -0,0 +1,34 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + 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", + }, + keyframes: { + "accent-pulse": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.35" }, + }, + }, + animation: { + "accent-pulse": "accent-pulse 1.6s ease-in-out infinite", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/pwa/tsconfig.json b/apps/pwa/tsconfig.json new file mode 100644 index 0000000..55b5dbb --- /dev/null +++ b/apps/pwa/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "outDir": "dist" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/pwa/vite.config.ts b/apps/pwa/vite.config.ts new file mode 100644 index 0000000..3b64789 --- /dev/null +++ b/apps/pwa/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; +import { VitePWA } from "vite-plugin-pwa"; +import path from "node:path"; + +export default defineConfig({ + plugins: [ + TanStackRouterVite(), + react(), + VitePWA({ + registerType: "autoUpdate", + includeAssets: ["favicon.ico", "icons/*.png"], + manifest: { + name: "ordinarthur-os", + short_name: "ordinarthur", + description: "Assistant personnel d'Arthur Barré", + theme_color: "#F5F1EA", + background_color: "#F5F1EA", + display: "standalone", + start_url: "/", + icons: [ + { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }, + { src: "/icons/icon-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + ], + }, + workbox: { + // Phase 2 : ajouter stale-while-revalidate sur GET /api/* + navigateFallback: "/index.html", + }, + }), + ], + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + server: { port: 5173, host: true }, +}); diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md new file mode 100644 index 0000000..b21ef96 --- /dev/null +++ b/deploy/k8s/README.md @@ -0,0 +1,24 @@ +# deploy/k8s + +Manifests Kubernetes pour le k3s perso d'Arthur. + +## Ordre d'application initial + +```bash +kubectl apply -f namespace.yaml +# Copier secrets.template.yaml -> secrets.yaml, remplir, puis : +kubectl apply -f secrets.yaml +kubectl apply -f api.deployment.yaml +kubectl apply -f pwa.deployment.yaml +kubectl apply -f ingress.yaml +# Une fois le bucket S3 choisi : +kubectl apply -f backup.cronjob.yaml +``` + +## Points à confirmer avec Arthur avant déploiement réel + +- Cluster issuer cert-manager (`letsencrypt-prod` ?) +- Entrée Traefik (`websecure` ?) +- DNS : `os.arthurbarre.fr` et `api.os.arthurbarre.fr` doivent pointer sur l'IP du load-balancer k3s +- Bucket S3-compatible pour les backups (B2 / Scaleway / autre) +- Image registry : Gitea CR (défaut) — credentials pull peuvent nécessiter un `imagePullSecrets` diff --git a/deploy/k8s/api.deployment.yaml b/deploy/k8s/api.deployment.yaml new file mode 100644 index 0000000..f9cbcfc --- /dev/null +++ b/deploy/k8s/api.deployment.yaml @@ -0,0 +1,41 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: ordinarthur-os +spec: + replicas: 1 + selector: { matchLabels: { app: api } } + template: + metadata: { labels: { app: api } } + spec: + containers: + - name: api + image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest + imagePullPolicy: Always + ports: [{ containerPort: 3000 }] + envFrom: + - secretRef: { name: ordinarthur-os-secrets } + env: + - { name: NODE_ENV, value: production } + - { name: PORT, value: "3000" } + readinessProbe: + httpGet: { path: /health, port: 3000 } + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: { path: /health, port: 3000 } + initialDelaySeconds: 15 + periodSeconds: 30 + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { cpu: 500m, memory: 512Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: api + namespace: ordinarthur-os +spec: + selector: { app: api } + ports: [{ port: 3000, targetPort: 3000 }] diff --git a/deploy/k8s/backup.cronjob.yaml b/deploy/k8s/backup.cronjob.yaml new file mode 100644 index 0000000..d72db44 --- /dev/null +++ b/deploy/k8s/backup.cronjob.yaml @@ -0,0 +1,29 @@ +# Backup quotidien du schéma ordinarthur_os. +# `PGURL` et `RCLONE_REMOTE` à fournir via secret séparé `ordinarthur-os-backup-secrets` +# (voir secrets.template.yaml — à splitter quand le bucket S3 est choisi avec Arthur). +apiVersion: batch/v1 +kind: CronJob +metadata: + name: ordinarthur-os-backup + namespace: ordinarthur-os +spec: + schedule: "0 3 * * *" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: pgdump + image: postgres:16-alpine + envFrom: + - secretRef: { name: ordinarthur-os-backup-secrets } + command: ["/bin/sh", "-c"] + args: + - | + set -euo pipefail + apk add --no-cache rclone + pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz + rclone copy /tmp/dump.gz "$RCLONE_REMOTE/$(date +%F).gz" diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..10faf90 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -0,0 +1,27 @@ +# À aligner sur la conf Traefik / cert-manager d'Arthur (cf. /Users/arthurbarre/dev/perso/proxmox). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ordinarthur-os + namespace: ordinarthur-os + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" +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 } } } diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml new file mode 100644 index 0000000..61d12e3 --- /dev/null +++ b/deploy/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ordinarthur-os diff --git a/deploy/k8s/pwa.deployment.yaml b/deploy/k8s/pwa.deployment.yaml new file mode 100644 index 0000000..fc456c0 --- /dev/null +++ b/deploy/k8s/pwa.deployment.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pwa + namespace: ordinarthur-os +spec: + replicas: 1 + selector: { matchLabels: { app: pwa } } + template: + metadata: { labels: { app: pwa } } + spec: + containers: + - name: pwa + image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest + imagePullPolicy: Always + ports: [{ containerPort: 80 }] + readinessProbe: + httpGet: { path: /, port: 80 } + initialDelaySeconds: 3 + resources: + requests: { cpu: 20m, memory: 32Mi } + limits: { cpu: 100m, memory: 128Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: pwa + namespace: ordinarthur-os +spec: + selector: { app: pwa } + ports: [{ port: 80, targetPort: 80 }] diff --git a/deploy/k8s/secrets.template.yaml b/deploy/k8s/secrets.template.yaml new file mode 100644 index 0000000..d343038 --- /dev/null +++ b/deploy/k8s/secrets.template.yaml @@ -0,0 +1,24 @@ +# NE PAS COMMITER LES VRAIES VALEURS. +# Ce fichier est un template — appliquer une copie remplie via : +# kubectl -n ordinarthur-os apply -f secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: ordinarthur-os-secrets + namespace: ordinarthur-os +type: Opaque +stringData: + API_BEARER_TOKEN: "" + SUPABASE_URL: "https://supabase.arthurbarre.fr" + SUPABASE_SERVICE_ROLE_KEY: "" + SUPABASE_SCHEMA: "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" + ICAL_FEED_SECRET: "" + TELEGRAM_BOT_TOKEN: "" + TELEGRAM_WEBHOOK_SECRET: "" diff --git a/package.json b/package.json new file mode 100644 index 0000000..23cef8e --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "ordinarthur-os", + "version": "0.0.0", + "private": true, + "packageManager": "pnpm@9.12.0", + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck" + }, + "devDependencies": { + "turbo": "^2.1.3", + "typescript": "^5.5.4" + }, + "engines": { + "node": ">=20.11" + } +} diff --git a/packages/db/README.md b/packages/db/README.md new file mode 100644 index 0000000..44e2e7f --- /dev/null +++ b/packages/db/README.md @@ -0,0 +1,15 @@ +# @ordinarthur-os/db + +Migrations SQL versionnées pour le schéma `ordinarthur_os` sur Supabase self-hosted. + +Pas d'outil de migration automatique pour l'instant : appliquer manuellement via le SQL editor de Supabase, dans l'ordre numérique. Chaque fichier doit être idempotent autant que possible (`create … if not exists`). + +## Ordre + +- `0001_schema.sql` — création du schéma + RLS service-role-only (Phase 0) +- `0002_jobs.sql` — tables `jobs`, `job_search_criteria` (Phase 1) +- `0003_todos.sql` — table `todos` + `client_mutations` (Phase 2) +- `0004_projects.sql` — `projects`, `project_steps`, `project_ideas` (Phase 3) +- `0005_agenda.sql` — `calendar_events`, `google_oauth_tokens` (Phase 4) +- `0006_ai.sql` — `ai_actions` (Phase 5) +- `0007_health.sql` — `daily_checkins` (Phase 7) diff --git a/packages/db/migrations/0001_schema.sql b/packages/db/migrations/0001_schema.sql new file mode 100644 index 0000000..a98f3a1 --- /dev/null +++ b/packages/db/migrations/0001_schema.sql @@ -0,0 +1,20 @@ +-- 0001_schema.sql — Phase 0 +-- Crée le schéma dédié `ordinarthur_os`. Les tables métier arrivent dans +-- les migrations suivantes (0002+). On pose ici uniquement le socle. + +create schema if not exists ordinarthur_os; + +-- pgcrypto pour gen_random_uuid() +create extension if not exists pgcrypto; + +comment on schema ordinarthur_os is + 'Assistant personnel single-user d''Arthur. Accès via service_role uniquement.'; + +-- Helper appliqué à chaque table créée dans les migrations suivantes : +-- +-- alter table ordinarthur_os. enable row level security; +-- create policy "_service_role" on ordinarthur_os. +-- for all using (auth.role() = 'service_role') +-- with check (auth.role() = 'service_role'); +-- +-- (Repris explicitement dans chaque migration de table.) diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..1ae2488 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ordinarthur-os/db", + "version": "0.0.0", + "private": true, + "scripts": { + "migrate:print": "ls migrations" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..5e27bdf --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,20 @@ +{ + "name": "@ordinarthur-os/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..73f990e --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Common +// --------------------------------------------------------------------------- + +export const HealthResponse = z.object({ + ok: z.literal(true), + version: z.string(), + uptime: z.number(), +}); +export type HealthResponse = z.infer; + +export const AuthVerifyResponse = z.object({ + ok: z.literal(true), +}); +export type AuthVerifyResponse = z.infer; + +// --------------------------------------------------------------------------- +// Phase 1 placeholders — fully fleshed out in Phase 1 +// --------------------------------------------------------------------------- + +export const RemoteType = z.enum(["remote", "hybrid", "onsite"]); +export type RemoteType = z.infer; + +// --------------------------------------------------------------------------- +// Phase 5 — AI proposed actions (kept here so PWA + API agree) +// --------------------------------------------------------------------------- + +export const ProposedAction = z.discriminatedUnion("fn", [ + z.object({ + fn: z.literal("create_todo"), + args: z.object({ + title: z.string(), + description: z.string().optional(), + due_at: z.string().datetime().optional(), + priority: z.number().int().min(0).max(3).optional(), + project_id: z.string().uuid().optional(), + tags: z.array(z.string()).optional(), + }), + }), + z.object({ + fn: z.literal("add_project_idea"), + args: z.object({ + project_id: z.string().uuid(), + content: z.string(), + }), + }), + z.object({ + fn: z.literal("add_project_step"), + args: z.object({ + project_id: z.string().uuid(), + title: z.string(), + status: z.enum(["backlog", "todo", "doing", "review", "done"]).optional(), + }), + }), + z.object({ + fn: z.literal("create_calendar_event"), + args: z.object({ + title: z.string(), + starts_at: z.string().datetime(), + ends_at: z.string().datetime(), + location: z.string().optional(), + description: z.string().optional(), + }), + }), + z.object({ + fn: z.literal("toggle_daily_checkin"), + args: z.object({ + note: z.string().optional(), + }), + }), +]); +export type ProposedAction = z.infer; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..8cd44fc --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..0db9a71 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": false, + "jsx": "preserve" + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..68ccd4f --- /dev/null +++ b/turbo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".vite/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": {}, + "typecheck": { + "dependsOn": ["^build"] + } + } +}