init
This commit is contained in:
commit
bc0c15873f
43
.gitea/workflows/deploy.yml
Normal file
43
.gitea/workflows/deploy.yml
Normal file
@ -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
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.turbo
|
||||||
|
.vite
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
541
ARCHITECTURE.md
Normal file
541
ARCHITECTURE.md
Normal file
@ -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
|
||||||
|
|
||||||
|
- `<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`
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@ -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`
|
||||||
119
PLAN.md
Normal file
119
PLAN.md
Normal file
@ -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 (`<Label>`, `<SectionHeader>`, `<GridFrame>`, `<DataChip>`, `<MetaRow>`) 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 <texte>` (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`
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@ -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`.
|
||||||
26
apps/api/.env.example
Normal file
26
apps/api/.env.example
Normal file
@ -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=
|
||||||
24
apps/api/Dockerfile
Normal file
24
apps/api/Dockerfile
Normal file
@ -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"]
|
||||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apps/api/package.json
Normal file
30
apps/api/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/api/src/app.module.ts
Normal file
23
apps/api/src/app.module.ts
Normal file
@ -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("*");
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/config/config.module.ts
Normal file
16
apps/api/src/config/config.module.ts
Normal file
@ -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 {}
|
||||||
42
apps/api/src/config/env.ts
Normal file
42
apps/api/src/config/env.ts
Normal file
@ -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<typeof EnvSchema>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
27
apps/api/src/db/db.module.ts
Normal file
27
apps/api/src/db/db.module.ts
Normal file
@ -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);
|
||||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@ -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();
|
||||||
14
apps/api/src/modules/auth/auth.controller.ts
Normal file
14
apps/api/src/modules/auth/auth.controller.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/modules/auth/auth.module.ts
Normal file
10
apps/api/src/modules/auth/auth.module.ts
Normal file
@ -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 {}
|
||||||
21
apps/api/src/modules/auth/bearer.middleware.ts
Normal file
21
apps/api/src/modules/auth/bearer.middleware.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/modules/health/health.controller.ts
Normal file
15
apps/api/src/modules/health/health.controller.ts
Normal file
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/modules/health/health.module.ts
Normal file
7
apps/api/src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { HealthController } from "./health.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
15
apps/api/tsconfig.json
Normal file
15
apps/api/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
1
apps/pwa/.env.example
Normal file
1
apps/pwa/.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
15
apps/pwa/Dockerfile
Normal file
15
apps/pwa/Dockerfile
Normal file
@ -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
|
||||||
16
apps/pwa/index.html
Normal file
16
apps/pwa/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#F5F1EA" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<title>ordinarthur-os</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-bg text-ink font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
apps/pwa/nginx.conf
Normal file
25
apps/pwa/nginx.conf
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/pwa/package.json
Normal file
34
apps/pwa/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/pwa/postcss.config.cjs
Normal file
6
apps/pwa/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
0
apps/pwa/public/.gitkeep
Normal file
0
apps/pwa/public/.gitkeep
Normal file
10
apps/pwa/public/icons/README.md
Normal file
10
apps/pwa/public/icons/README.md
Normal file
@ -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`.
|
||||||
47
apps/pwa/src/api/client.ts
Normal file
47
apps/pwa/src/api/client.ts
Normal file
@ -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<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit & { auth?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const { auth = true, headers, ...rest } = init;
|
||||||
|
const finalHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(headers as Record<string, string> | 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<T>;
|
||||||
|
}
|
||||||
13
apps/pwa/src/design/AccentDot.tsx
Normal file
13
apps/pwa/src/design/AccentDot.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
/** Point orange qui pulse — marqueur "live/disponible". */
|
||||||
|
export function AccentDot({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-2 w-2 rounded-full bg-accent animate-accent-pulse",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/pwa/src/design/BigHeading.tsx
Normal file
25
apps/pwa/src/design/BigHeading.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface BigHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||||
|
as?: "h1" | "h2";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gros titre éditorial. Italique = orange (convention portfolio).
|
||||||
|
* Wrap n'importe quel texte en <em> pour qu'il passe en accent.
|
||||||
|
*/
|
||||||
|
export function BigHeading({ as: Tag = "h1", className, children, ...props }: BigHeadingProps) {
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={cn(
|
||||||
|
"font-sans font-light tracking-tightest text-ink",
|
||||||
|
"text-[clamp(2.5rem,8vw,6rem)] leading-[0.95]",
|
||||||
|
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/pwa/src/design/DataChip.tsx
Normal file
25
apps/pwa/src/design/DataChip.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface DataChipProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
|
dotColor?: "accent" | "ink";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataChip({ dotColor = "ink", className, children, ...props }: DataChipProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 border border-ink px-2 py-1 font-mono text-[11px] uppercase tracking-label",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-1.5 w-1.5 rounded-full",
|
||||||
|
dotColor === "accent" ? "bg-accent" : "bg-ink",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/pwa/src/design/GridFrame.tsx
Normal file
20
apps/pwa/src/design/GridFrame.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface GridFrameProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
cols?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridFrame({ cols = 12, className, children, ...props }: GridFrameProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid border border-ink divide-x divide-ink",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/pwa/src/design/Label.tsx
Normal file
24
apps/pwa/src/design/Label.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
interface LabelProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini-label éditorial : mono, uppercase, 11px, tracking-label, muted.
|
||||||
|
* Ex: <Label prefix="[ 01 ]">À PROPOS</Label>
|
||||||
|
*/
|
||||||
|
export function Label({ prefix, className, children, ...props }: LabelProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-[11px] uppercase tracking-label text-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{prefix ? <span className="mr-2 text-ink">{prefix}</span> : null}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/pwa/src/design/MetaRow.tsx
Normal file
17
apps/pwa/src/design/MetaRow.tsx
Normal file
@ -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 (
|
||||||
|
<div className={cn("flex items-baseline gap-6 border-b border-ink py-3", className)}>
|
||||||
|
<Label className="w-32 shrink-0">{label}</Label>
|
||||||
|
<div className="flex-1 font-sans text-sm text-ink">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/pwa/src/design/SectionHeader.tsx
Normal file
29
apps/pwa/src/design/SectionHeader.tsx
Normal file
@ -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 (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"border border-ink divide-y divide-ink",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
|
<Label prefix={`[ ${number} ]`}>{label}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-6">
|
||||||
|
<h2 className="font-sans font-light tracking-tightest text-3xl md:text-5xl text-ink">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/pwa/src/design/index.ts
Normal file
7
apps/pwa/src/design/index.ts
Normal file
@ -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";
|
||||||
6
apps/pwa/src/lib/cn.ts
Normal file
6
apps/pwa/src/lib/cn.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import clsx, { type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
26
apps/pwa/src/main.tsx
Normal file
26
apps/pwa/src/main.tsx
Normal file
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
26
apps/pwa/src/routeTree.gen.ts
Normal file
26
apps/pwa/src/routeTree.gen.ts
Normal file
@ -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 */
|
||||||
46
apps/pwa/src/routes/__root.tsx
Normal file
46
apps/pwa/src/routes/__root.tsx
Normal file
@ -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 (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="border-b border-ink">
|
||||||
|
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<AccentDot />
|
||||||
|
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-4">
|
||||||
|
{/* Routes activées au fur et à mesure des phases */}
|
||||||
|
<NavLink to="/">Dashboard</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<footer className="border-t border-ink">
|
||||||
|
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||||
|
<Label>v0.0.0 · phase 0</Label>
|
||||||
|
<Label>arthurbarre.fr</Label>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink [&.active]:text-ink"
|
||||||
|
activeProps={{ className: "active" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/pwa/src/routes/index.tsx
Normal file
72
apps/pwa/src/routes/index.tsx
Normal file
@ -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 (
|
||||||
|
<div className="space-y-12">
|
||||||
|
<section>
|
||||||
|
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label>
|
||||||
|
<BigHeading className="mt-4">
|
||||||
|
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
||||||
|
</BigHeading>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SectionHeader
|
||||||
|
number="01"
|
||||||
|
label="STATUS"
|
||||||
|
title="Backend handshake"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GridFrame cols={2}>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
<Label>API HEALTH</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
|
||||||
|
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
|
||||||
|
</DataChip>
|
||||||
|
{health.data && (
|
||||||
|
<span className="font-mono text-[11px] text-muted">
|
||||||
|
v{health.data.version} · uptime {health.data.uptime}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
<Label>STACK</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<DataChip>VITE · REACT</DataChip>
|
||||||
|
<DataChip>NESTJS</DataChip>
|
||||||
|
<DataChip>SUPABASE</DataChip>
|
||||||
|
<DataChip>K3S</DataChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GridFrame>
|
||||||
|
|
||||||
|
<section className="border border-ink">
|
||||||
|
<div className="px-4 py-3 border-b border-ink">
|
||||||
|
<Label prefix="[ 02 ]">ROADMAP</Label>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
<MetaRow label="PHASE 0" value="Scaffold (en cours)" />
|
||||||
|
<MetaRow label="PHASE 1" value="Jobs · prio remontée" />
|
||||||
|
<MetaRow label="PHASE 2" value="Todos riches" />
|
||||||
|
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
||||||
|
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
||||||
|
<MetaRow label="PHASE 5" value="IA texte + voice magic button" />
|
||||||
|
<MetaRow label="PHASE 6" value="Telegram bot" />
|
||||||
|
<MetaRow label="PHASE 7" value="Health tab" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/pwa/src/styles.css
Normal file
16
apps/pwa/src/styles.css
Normal file
@ -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); }
|
||||||
|
}
|
||||||
34
apps/pwa/tailwind.config.ts
Normal file
34
apps/pwa/tailwind.config.ts
Normal file
@ -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;
|
||||||
16
apps/pwa/tsconfig.json
Normal file
16
apps/pwa/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
38
apps/pwa/vite.config.ts
Normal file
38
apps/pwa/vite.config.ts
Normal file
@ -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 },
|
||||||
|
});
|
||||||
24
deploy/k8s/README.md
Normal file
24
deploy/k8s/README.md
Normal file
@ -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`
|
||||||
41
deploy/k8s/api.deployment.yaml
Normal file
41
deploy/k8s/api.deployment.yaml
Normal file
@ -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 }]
|
||||||
29
deploy/k8s/backup.cronjob.yaml
Normal file
29
deploy/k8s/backup.cronjob.yaml
Normal file
@ -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"
|
||||||
27
deploy/k8s/ingress.yaml
Normal file
27
deploy/k8s/ingress.yaml
Normal file
@ -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 } } }
|
||||||
4
deploy/k8s/namespace.yaml
Normal file
4
deploy/k8s/namespace.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: ordinarthur-os
|
||||||
31
deploy/k8s/pwa.deployment.yaml
Normal file
31
deploy/k8s/pwa.deployment.yaml
Normal file
@ -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 }]
|
||||||
24
deploy/k8s/secrets.template.yaml
Normal file
24
deploy/k8s/secrets.template.yaml
Normal file
@ -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: ""
|
||||||
19
package.json
Normal file
19
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
packages/db/README.md
Normal file
15
packages/db/README.md
Normal file
@ -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)
|
||||||
20
packages/db/migrations/0001_schema.sql
Normal file
20
packages/db/migrations/0001_schema.sql
Normal file
@ -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.<t> enable row level security;
|
||||||
|
-- create policy "<t>_service_role" on ordinarthur_os.<t>
|
||||||
|
-- for all using (auth.role() = 'service_role')
|
||||||
|
-- with check (auth.role() = 'service_role');
|
||||||
|
--
|
||||||
|
-- (Repris explicitement dans chaque migration de table.)
|
||||||
8
packages/db/package.json
Normal file
8
packages/db/package.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@ordinarthur-os/db",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"migrate:print": "ls migrations"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/shared/src/index.ts
Normal file
74
packages/shared/src/index.ts
Normal file
@ -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<typeof HealthResponse>;
|
||||||
|
|
||||||
|
export const AuthVerifyResponse = z.object({
|
||||||
|
ok: z.literal(true),
|
||||||
|
});
|
||||||
|
export type AuthVerifyResponse = z.infer<typeof AuthVerifyResponse>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 1 placeholders — fully fleshed out in Phase 1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
|
||||||
|
export type RemoteType = z.infer<typeof RemoteType>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<typeof ProposedAction>;
|
||||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
turbo.json
Normal file
17
turbo.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user