Compare commits

...

10 Commits

Author SHA1 Message Date
ordinarthur
be01895c92 ci: add kubectl install + idempotent manifest apply (rebours pattern)
Some checks failed
Build & Deploy / deploy (push) Failing after 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 16:50:13 +02:00
ordinarthur
6fdfbab996 add project 2026-04-16 16:23:01 +02:00
ordinarthur
7de7ef16b9 add agenda 2026-04-16 14:20:00 +02:00
ordinarthur
242abdba5d update guidelines 2026-04-16 12:46:52 +02:00
ordinarthur
22e5ed1a15 add components 2026-04-16 12:45:20 +02:00
ordinarthur
d55a552d2e add medications 2026-04-16 12:40:40 +02:00
ordinarthur
b71d5c8f47 add magic button 2026-04-16 10:57:26 +02:00
ordinarthur
32f3105bef pg todo 2 2026-04-16 10:46:57 +02:00
ordinarthur
f851da4677 add pg + todo ok 2026-04-16 10:46:51 +02:00
ordinarthur
9c93e74318 replace Supabase with Postgres + Drizzle ORM
- Drop @supabase/supabase-js entirely; add drizzle-orm + postgres (porsager) driver
- New packages/db: schema (pgSchema ordinarthur_os), client factory, migrate runner, drizzle-kit config
- SQL migrations: 0000_init (pgcrypto + schema), 0001_jobs (jobs + job_search_criteria, no RLS)
- Rewrite apps/api db module with DI symbols DB/DB_HANDLE + @InjectDb() decorator
- Rewrite jobs.service.ts with Drizzle queries (upsert via onConflictDoUpdate, arrayOverlaps for stack filter)
- Replace SUPABASE_* env vars with DATABASE_URL in env config + .env.example
- Add docker-compose.yml (Postgres 16-alpine, dev only)
- Add deploy/k8s/postgres.yaml (StatefulSet + PVC), migrate.job.yaml, updated secrets.template.yaml
- Update all docs (README, PLAN, ARCHITECTURE, CLAUDE.md, AGENTS.md, packages/db/README.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:15:34 +02:00
99 changed files with 6990 additions and 565 deletions

View File

@ -0,0 +1,50 @@
{
"permissions": {
"allow": [
"Bash(pnpm --filter @ordinarthur-os/db build)",
"Bash(pnpm --filter @ordinarthur-os/db tsc --noEmit)",
"Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)",
"Read(//private/tmp/test_ts/pkg/**)",
"Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")",
"Bash(pnpm --filter @ordinarthur-os/db migrate)",
"Bash(pnpm --filter @ordinarthur-os/shared build)",
"Bash(pnpm --filter @ordinarthur-os/shared typecheck)",
"Bash(pnpm --filter @ordinarthur-os/api typecheck)",
"Bash(pnpm --filter @ordinarthur-os/pwa typecheck)",
"Bash(pnpm -r typecheck)",
"Bash(pnpm --filter pwa build)",
"Bash(pnpm --filter api build)",
"Bash(pnpm --filter @ordinarthur-os/pwa add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities)",
"Bash(pnpm --filter @ordinarthur-os/api build)",
"Bash(grep -v \"^$\")",
"Bash(node -e \"const m = require\\('./apps/api/dist/main'\\); \")",
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get nodes)",
"Bash(ping -c 2 -W 2 10.10.10.5)",
"Bash(ssh -o ConnectTimeout=5 root@100.78.114.17 \"qm list\")",
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"ssh arthur@10.10.10.5 'sudo journalctl -n 30 -u k3s --no-pager' 2>&1\")",
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"qm status 103 && qm agent 103 exec -- bash -c 'df -h / && dmesg | tail -20'\")",
"Bash(ssh -o ConnectTimeout=10 root@100.78.114.17 \"qm status 103 --verbose\")",
"Bash(ssh root@100.78.114.17 \"qm terminal 103\")",
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get pods -n gitea)",
"Bash(kubectl --kubeconfig ~/dev/perso/proxmox/k3s/kubeconfig.yaml get svc -n gitea)",
"Bash(ssh arthur@100.106.59.13 \"sudo systemctl status traefik --no-pager | head -20\")",
"Bash(ssh arthur@100.106.59.13 \"curl -s http://localhost:8080/api/http/services 2>&1 | head -100\")",
"Bash(ssh arthur@100.106.59.13 \"cat /etc/traefik/traefik.yml\")",
"Read(//Users/arthurbarre/.config/gitea/**)",
"Bash(curl -s -X POST https://git.arthurbarre.fr/api/v1/user/repos -H 'Authorization: token __TRACKED_VAR__' -H 'Content-Type: application/json' -d '{\"name\":\"ordinarthur-os\",\"private\":false,\"auto_init\":false}')",
"Bash(ssh arthur@100.78.207.119 \"sudo -u postgres psql -c \\\\\"SELECT usename FROM pg_user WHERE usename = 'ordinarthur';\\\\\"\")",
"Bash(ssh arthur@100.114.242.60 \"sudo -u postgres psql -c \\\\\"\\\\\\\\du\\\\\" 2>&1 | grep -E 'ordinarthur|postgres'\")",
"Bash(openssl rand:*)",
"Bash(ssh arthur@100.114.242.60:*)",
"Bash(ansible-playbook playbooks/gateway.yml)",
"Bash(docker login:*)",
"Bash(ssh arthur@100.78.207.119 \"nslookup git.arthurbarre.fr && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
"Bash(ssh arthur@100.78.207.119 \"cat /etc/resolv.conf && echo '---' && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
"Bash(ssh arthur@100.78.207.119 \"curl -v --max-time 10 https://git.arthurbarre.fr/v2/ 2>&1 | head -30\")",
"Bash(ssh arthur@100.78.207.119 \"ping -c 2 1.1.1.1 && curl -s -o /dev/null -w '%{http_code}' https://51.38.62.199 -H 'Host: git.arthurbarre.fr' --insecure\")",
"Bash(ssh arthur@100.78.207.119 \"echo '51.38.62.199 git.arthurbarre.fr' | sudo tee -a /etc/hosts && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
"Bash(ssh arthur@100.121.251.87 \"echo '51.38.62.199 git.arthurbarre.fr' | sudo tee -a /etc/hosts && curl -s -o /dev/null -w '%{http_code}' https://git.arthurbarre.fr/v2/\")",
"Bash(curl -s -X POST https://git.arthurbarre.fr/api/v1/user/repos -H 'Authorization: token __TRACKED_VAR__' -H 'Content-Type: application/json' -d '{\"name\":\"rebours\",\"private\":false,\"auto_init\":false}')"
]
}
}

View File

@ -1,42 +1,64 @@
# 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)
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
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: Set image tags
run: |
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
echo "API_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:$SHA" >> $GITHUB_ENV
echo "PWA_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:$SHA" >> $GITHUB_ENV
- 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
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.arthurbarre.fr -u ordinarthur --password-stdin
- name: Deploy on k3s
- name: Build & push API
run: |
docker build -f apps/api/Dockerfile -t "$API_TAG" .
docker push "$API_TAG"
- name: Build & push PWA
run: |
docker build \
--build-arg VITE_API_BASE_URL=https://api.os.arthurbarre.fr \
-f apps/pwa/Dockerfile \
-t "$PWA_TAG" .
docker push "$PWA_TAG"
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl && mv kubectl /usr/local/bin/kubectl
- name: Deploy on K3s
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
# Appliquer les manifests (idempotent)
kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pwa.deployment.yaml
kubectl apply -f deploy/k8s/api.deployment.yaml
# Pull secret (idempotent)
kubectl -n ordinarthur-os create secret docker-registry gitea-registry \
--docker-server=git.arthurbarre.fr \
--docker-username=ordinarthur \
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
--dry-run=client -o yaml | kubectl apply -f -
# Rollout
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

View File

@ -13,7 +13,7 @@ Avant toute action, lire dans cet ordre :
- **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`).
- **Postgres standalone dans le k3s** (plus de Supabase), schéma dédié `ordinarthur_os`. ORM = **Drizzle** (via `packages/db`).
## Phases
@ -23,7 +23,7 @@ Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) r
- Monorepo pnpm + Turborepo
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM)
- `apps/api` NestJS (modules par domaine), Drizzle ORM via `@ordinarthur-os/db`
- `packages/shared` types + zod DTOs partagés PWA ↔ API
- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant
- `packages/db` schéma Drizzle + `migrations/` SQL versionnées (runner `pnpm --filter @ordinarthur-os/db migrate`)
- Pas de fichier `.env` commité, juste `.env.example`

View File

@ -12,15 +12,15 @@
┌──────────────┬──────────────────┼────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
┌────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌────────────┐
Supabase │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
self-host │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
Postgres │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
(k3s STS) │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
│ schema │ │ function- │ │ │ │ │ │ │
│ ordinarthur│ │ calling │ │ │ │ │ │ │
│ _os │ │ │ │ │ │ │ │ │
└────────────┘ └─────────────┘ └──────────────┘ └────────────┘ └────────────┘
```
La PWA ne parle **jamais** directement à Supabase ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token.
La PWA ne parle **jamais** directement à Postgres ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token. Le service Postgres est en ClusterIP interne au k3s, jamais exposé.
## 2. Monorepo
@ -33,7 +33,12 @@ ordinarthur-os/
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
│ │ └── src/
│ │ ├── routes/ # / /jobs /todos /projects /agenda /health /settings/*
│ │ ├── components/ # Composants métier
│ │ ├── components/ # Composants métier, organisés par domaine
│ │ │ ├── ai/ # MagicButton, VoiceConfirmModal
│ │ │ ├── health/ # MedsSlider (UI pure), MedsSection (data + layout)
│ │ │ ├── jobs/
│ │ │ ├── todos/
│ │ │ └── …
│ │ ├── design/ # Tokens + primitives éditoriales
│ │ ├── api/ # Client HTTP typé, zod parse
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
@ -52,20 +57,28 @@ ordinarthur-os/
│ │ ├── ai/ # command, voice, function dispatcher
│ │ ├── telegram/
│ │ └── sync/ # replay client_mutations, dedup
│ ├── db/ # supabase client factory
│ ├── db/ # Drizzle client factory (@ordinarthur-os/db)
│ ├── config/ # env schema (zod)
│ └── main.ts
├── packages/
│ ├── shared/ # Types + zod DTOs partagés pwa/api
│ └── db/
│ └── migrations/ # 0001_schema.sql, 0002_jobs.sql, …
│ └── db/ # Drizzle ORM : schema TS + migrations SQL
│ ├── drizzle.config.ts
│ ├── src/
│ │ ├── schema/ # Définitions tables (pgSchema ordinarthur_os)
│ │ ├── client.ts # createDb(connectionString)
│ │ └── migrate.ts # runner DATABASE_URL-driven
│ └── migrations/ # 0000_init.sql, 0001_jobs.sql, … + meta/_journal.json
├── deploy/
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
│ ├── namespace.yaml
│ ├── postgres.yaml # StatefulSet Postgres 16 + PVC
│ ├── api.deployment.yaml
│ ├── pwa.deployment.yaml
│ ├── ingress.yaml
│ ├── migrate.job.yaml # Job one-shot drizzle migrate
│ ├── secrets.template.yaml
│ └── backup.cronjob.yaml
@ -115,6 +128,7 @@ theme: {
### Règles visuelles
- Le produit reste un outil personnel avant tout : clarté d'usage et vitesse priment sur la démonstration visuelle
- Bordures partout : sections, grid cells, nav
- Pas d'ombre portée (shadow-none)
- Imagery grayscale par défaut
@ -122,7 +136,53 @@ theme: {
- 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`
## 4. Conventions composants PWA
### Séparation UI / data
Chaque feature suit un découpage strict en deux niveaux :
| Niveau | Fichier | Responsabilité |
| --- | --- | --- |
| **UI pure** | `ComponentName.tsx` | Props uniquement, aucun `useQuery`/`useMutation`, pas d'import `api`. Testable en isolation. |
| **Section data** | `ComponentNameSection.tsx` | Encapsule le `useQuery` + `useMutation` TanStack Query, compose le composant UI, gère l'état de chargement. |
La route (`routes/*.tsx`) ne fait jamais de fetching pour une feature tierce — elle importe la `Section` correspondante et la pose.
**Exemple : médocs**
```
components/health/
MedsSlider.tsx ← drag UI, props: medsTaken / onToggle / disabled
MedsSection.tsx ← query GET /health-tab/today + mutation POST toggle
```
```tsx
// routes/index.tsx — la route ne sait rien de l'API health
import { MedsSection } from "@/components/health/MedsSection";
// …
<MedsSection />
```
### Organisation des dossiers `components/`
Un sous-dossier par domaine métier, aligné sur les modules NestJS :
```
components/
ai/ # MagicButton, VoiceConfirmModal
health/ # MedsSlider, MedsSection
jobs/
todos/
projects/
agenda/
```
Pas de dossier `common/` fourre-tout. Les primitives partagées (bordures, labels, empty states) vivent dans `design/`.
## 5. Schéma Postgres `ordinarthur_os`
Source de vérité : les définitions Drizzle dans [`packages/db/src/schema/`](./packages/db/src/schema/). Le SQL ci-dessous est l'équivalent dénormalisé, à titre de référence — les migrations réelles vivent dans `packages/db/migrations/`.
```sql
create schema if not exists ordinarthur_os;
@ -283,9 +343,9 @@ create table client_mutations (
);
```
**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.
**Pas de RLS.** La base n'est jamais exposée : le seul client SQL est le backend NestJS (ClusterIP interne au k3s, credentials Postgres classiques). La PWA est derrière le bearer Nest.
## 5. API NestJS — routes
## 6. API NestJS — routes
Middleware global : `BearerGuard` sauf `/health`, `/telegram/webhook` (signé autrement), `/agenda/ical/:secret.ics` (secret URL).
@ -365,7 +425,7 @@ type ProposedAction =
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
## 7. PWA — routing & pages
```
/ Dashboard (events du jour, todos due today, médocs, bouton 🎤, Cmd-K)
@ -388,7 +448,7 @@ Flow garanti : l'API **ne jamais** exécute une action directement. Elle renvoie
- 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
## 8. Voice magic button
1. `MediaRecorder` dans le navigateur → blob WebM/Opus
2. `POST /ai/voice` (multipart) vers NestJS
@ -399,7 +459,7 @@ Flow garanti : l'API **ne jamais** exécute une action directement. Elle renvoie
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
## 9. Déploiement k3s
### Ingress
@ -427,10 +487,9 @@ spec:
### Secrets k8s attendus
`ordinarthur-os-secrets` :
`ordinarthur-os-secrets` (consommé par l'API) :
- `API_BEARER_TOKEN`
- `SUPABASE_URL` (`https://supabase.arthurbarre.fr`)
- `SUPABASE_SERVICE_ROLE_KEY`
- `DATABASE_URL` (`postgres://<user>:<pwd>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os`)
- `MISTRAL_API_KEY`
- `GROQ_API_KEY`
- `GOOGLE_OAUTH_CLIENT_ID`
@ -440,6 +499,13 @@ spec:
- `TELEGRAM_WEBHOOK_SECRET`
- `ICAL_FEED_SECRET`
`ordinarthur-os-db-secrets` (consommé par le StatefulSet Postgres) :
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
`ordinarthur-os-backup-secrets` (CronJob) :
- `PGURL` (même valeur que `DATABASE_URL`)
- `RCLONE_REMOTE` (ex. `b2:ordinarthur-os-backups`)
### CronJob backup
```yaml
@ -462,10 +528,11 @@ spec:
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
rclone copy /tmp/dump.gz "$RCLONE_REMOTE/$(date +%F).gz"
```
> `PGURL` = même chaîne `postgres://…` que `DATABASE_URL`. Le schéma reste dédié (`--schema=ordinarthur_os`) pour faciliter d'éventuelles restaurations sélectives.
### Pipeline Gitea (à aligner avec le skill `/deploy` d'Arthur)
```yaml
@ -494,16 +561,16 @@ jobs:
kubectl set image deploy/pwa pwa=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
```
## 9. Sécurité
## 10. Sécurité
- TLS everywhere via cert-manager
- Bearer token Nest : stocké seulement dans les secrets k8s, jamais dans le bundle PWA → la PWA demande le token à l'utilisateur (écran d'onboarding) et le stocke dans `localStorage` (accès par Arthur uniquement sur son device)
- Webhook Telegram : vérification de `X-Telegram-Bot-Api-Secret-Token`
- Feed iCal : path contient un secret rotable (`ICAL_FEED_SECRET`)
- Supabase : schema dédié + RLS service-role-only
- Postgres : service ClusterIP interne au k3s, jamais exposé ; credentials via Secret k8s
- Backups : quotidien, chiffrés au repos côté bucket, rétention 30 jours
## 10. Observabilité (phase ultérieure)
## 11. Observabilité (phase ultérieure)
- Logs Nest en JSON → si Arthur a déjà Loki/Grafana, les envoyer là
- Health probes Liveness / Readiness sur `/health`
@ -519,9 +586,7 @@ jobs:
NODE_ENV=production
PORT=3000
API_BEARER_TOKEN=
SUPABASE_URL=https://supabase.arthurbarre.fr
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_SCHEMA=ordinarthur_os
DATABASE_URL=postgres://ordinarthur:changeme@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os
MISTRAL_API_KEY=
MISTRAL_MODEL=mistral-small-latest
GROQ_API_KEY=

View File

@ -13,7 +13,7 @@ Avant toute action, lire dans cet ordre :
- **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`).
- **Postgres standalone dans le k3s** (plus de Supabase), schéma dédié `ordinarthur_os`. ORM = **Drizzle** (via `packages/db`).
## Phases
@ -23,7 +23,7 @@ Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) r
- Monorepo pnpm + Turborepo
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM)
- `apps/api` NestJS (modules par domaine), Drizzle ORM via `@ordinarthur-os/db`
- `packages/shared` types + zod DTOs partagés PWA ↔ API
- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant
- `packages/db` schéma Drizzle + `migrations/` SQL versionnées (runner `pnpm --filter @ordinarthur-os/db migrate`)
- Pas de fichier `.env` commité, juste `.env.example`

24
PLAN.md
View File

@ -9,14 +9,14 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
- 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
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → Groq Whisper → Mistral function calling → création/enrichissement de todos, idées ou étapes projet, avec validation explicite avant écriture en base. **C'est le cœur du produit.** Les formulaires manuels sont intentionnellement minimalistes.
## 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.
4. **BFF unique**. La PWA ne parle qu'au NestJS. Le Nest parle à Postgres, Mistral, Groq, Google Calendar, Telegram.
5. **Design éditorial / Swiss-brutalist** — mirror du portfolio arthurbarre.fr (cream, ink, orange, borders, mono labels).
## Stack verrouillée
@ -25,8 +25,8 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
| --- | --- |
| 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` |
| Backend | NestJS + Drizzle ORM (driver `postgres`) |
| DB | Postgres 16 standalone (k3s StatefulSet + PVC), 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` |
@ -41,9 +41,9 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
### 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é
- `apps/api` : NestJS + module `health` + middleware bearer + client Drizzle initialisé
- `packages/shared` : types et zod schemas partagés
- `packages/db` : premier fichier de migration SQL créant le schéma `ordinarthur_os`
- `packages/db` : schéma Drizzle + premières migrations SQL (`0000_init` crée 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`
@ -57,11 +57,11 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
- 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 2 — Todos (capture rapide)
- Migration : table `todos`
- API : CRUD complet (schéma riche pour que la Phase 5 puisse tout remplir via function calling)
- PWA : **UI volontairement minimale** — textarea de capture + liste plate (checkbox done / delete). Pas de filtres, pas de formulaire de métadonnées. La priorité, le contexte, les tags seront remplis par le **magic button vocal (Phase 5)**.
- Les champs riches (priority, context, tags, due_at, checklist…) restent dans le schéma DB et l'API pour la Phase 5.
### Phase 3 — Projets + Kanban
- Migrations : `projects`, `project_steps`, `project_ideas`
@ -106,7 +106,7 @@ Pour reprendre ce projet avec Claude Code (Sonnet) :
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`
- Les secrets nécessaires : `MISTRAL_API_KEY`, `GROQ_API_KEY`, `TELEGRAM_BOT_TOKEN`, `GOOGLE_OAUTH_CLIENT_ID/SECRET`, `DATABASE_URL` (+ `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB` côté StatefulSet), `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.

View File

@ -1,6 +1,6 @@
# 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`).
Assistant personnel self-hosted d'Arthur Barré. PWA installable + backend NestJS, déployés sur k3s personnel, adossés à un Postgres standalone (schéma `ordinarthur_os`) piloté via Drizzle ORM.
**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.

View File

@ -4,10 +4,9 @@ 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
# Postgres standalone (k3s ou local docker). Le schéma `ordinarthur_os`
# est géré par Drizzle, pas besoin de `search_path` dans l'URL.
DATABASE_URL=postgres://ordinarthur:changeme@localhost:5432/ordinarthur_os
# Phase 5+
MISTRAL_API_KEY=

View File

@ -1,24 +1,42 @@
# 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
# ── Stage 1 : build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /workspace
FROM deps AS build
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
# package.json seuls d'abord (layer cache pnpm install)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
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
# Sources complètes
COPY . .
# Build dans l'ordre des dépendances
RUN pnpm --filter @ordinarthur-os/shared build
RUN pnpm --filter @ordinarthur-os/db build
RUN pnpm --filter @ordinarthur-os/api build
# Deploy propre : pnpm résout les workspace packages compilés vers /deploy/api
RUN pnpm --filter @ordinarthur-os/api deploy --prod /deploy/api
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
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
# Deps de prod isolées (inclut @ordinarthur-os/shared+db compilés)
COPY --from=builder /deploy/api/node_modules ./node_modules
COPY --from=builder /deploy/api/package.json ./
# Code compilé de l'API
COPY --from=builder /workspace/apps/api/dist ./dist
# Migrations SQL (pour le job K3s migrate)
COPY --from=builder /workspace/packages/db/migrations ./packages/db/migrations
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@ -13,10 +13,13 @@
"@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.4.4",
"@nestjs/platform-express": "^10.4.4",
"@ordinarthur-os/db": "workspace:*",
"@ordinarthur-os/shared": "workspace:*",
"@supabase/supabase-js": "^2.45.4",
"drizzle-orm": "^0.36.4",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"tsconfig-paths": "^4.2.0",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@ -5,9 +5,25 @@ import { HealthModule } from "./modules/health/health.module";
import { AuthModule } from "./modules/auth/auth.module";
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
import { JobsModule } from "./modules/jobs/jobs.module";
import { TodosModule } from "./modules/todos/todos.module";
import { AiModule } from "./modules/ai/ai.module";
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
import { AgendaModule } from "./modules/agenda/agenda.module";
import { ProjectsModule } from "./modules/projects/projects.module";
@Module({
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
imports: [
ConfigModule,
DbModule,
HealthModule,
AuthModule,
JobsModule,
TodosModule,
AiModule,
HealthTabModule,
AgendaModule,
ProjectsModule,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
@ -15,9 +31,8 @@ export class AppModule implements NestModule {
.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 },
{ path: "agenda/ical/:secret", method: RequestMethod.GET },
{ path: "agenda/google/oauth/callback", method: RequestMethod.GET },
)
.forRoutes("*");
}

View File

@ -1,6 +1,13 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { existsSync } from "node:fs";
import path from "node:path";
// Charge apps/api/.env (si présent) avant de valider process.env.
// Utilise l'API native Node 20.12+ — pas de dépendance `dotenv`.
const envPath = path.resolve(__dirname, "..", "..", ".env");
if (existsSync(envPath)) {
process.loadEnvFile(envPath);
}
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
@ -8,15 +15,20 @@ const EnvSchema = z.object({
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"),
// Postgres standalone (k3s) — pas de Supabase.
// Le driver `postgres` impose d'inclure le search_path via
// `?options=-c%20search_path%3Dordinarthur_os%2Cpublic` n'est PAS nécessaire
// car Drizzle préfixe lui-même le schéma (`pgSchema`).
DATABASE_URL: z.string().url(),
// 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"),
PWA_URL: z.string().url().default("http://localhost:5173"),
GOOGLE_OAUTH_CLIENT_ID: z.string().optional(),
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(),
@ -32,53 +44,12 @@ let cached: AppConfig | null = null;
export function loadConfig(): AppConfig {
if (cached) return cached;
loadEnvFile();
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;
}
function loadEnvFile() {
const candidates = [
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "apps/api/.env"),
path.resolve(__dirname, "../../.env"),
];
const envPath = candidates.find((candidate) => existsSync(candidate));
if (!envPath) return;
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separatorIndex = trimmed.indexOf("=");
if (separatorIndex === -1) continue;
const key = trimmed.slice(0, separatorIndex).trim();
const value = trimmed.slice(separatorIndex + 1).trim();
if (!key || process.env[key] !== undefined) continue;
process.env[key] = stripWrappingQuotes(value);
}
}
function stripWrappingQuotes(value: string) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}

View File

@ -1,27 +1,35 @@
import { Global, Module, Inject } from "@nestjs/common";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { Global, Inject, Module, type OnApplicationShutdown } from "@nestjs/common";
import { createDb, type Db, type DbHandle } from "@ordinarthur-os/db";
import { APP_CONFIG } from "../config/config.module";
import type { AppConfig } from "../config/env";
export const SUPABASE = Symbol("SUPABASE");
export type Supabase = SupabaseClient<any, any, any, any, any>;
export const DB = Symbol("DB");
export const DB_HANDLE = Symbol("DB_HANDLE");
export type { Db };
@Global()
@Module({
providers: [
{
provide: SUPABASE,
provide: DB_HANDLE,
inject: [APP_CONFIG],
useFactory: (config: AppConfig): Supabase =>
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
db: { schema: config.SUPABASE_SCHEMA },
}),
useFactory: (config: AppConfig): DbHandle => createDb(config.DATABASE_URL),
},
{
provide: DB,
inject: [DB_HANDLE],
useFactory: (handle: DbHandle): Db => handle.db,
},
],
exports: [SUPABASE],
exports: [DB, DB_HANDLE],
})
export class DbModule {}
export class DbModule implements OnApplicationShutdown {
constructor(@Inject(DB_HANDLE) private readonly handle: DbHandle) {}
// Convenience decorator : `constructor(@InjectSupabase() private db: Supabase) {}`
export const InjectSupabase = () => Inject(SUPABASE);
async onApplicationShutdown() {
await this.handle.close();
}
}
/** `constructor(@InjectDb() private db: Db) {}` */
export const InjectDb = () => Inject(DB);

View File

@ -1,6 +1,8 @@
import "reflect-metadata";
import "tsconfig-paths/register";
import { NestFactory } from "@nestjs/core";
import { Logger } from "@nestjs/common";
import { raw } from "express";
import { AppModule } from "./app.module";
import { loadConfig } from "./config/env";
@ -9,6 +11,12 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ["log", "warn", "error"],
});
// `/ai/voice` reçoit le blob audio brut — on court-circuite le json parser
// par défaut pour exposer `req.body` en tant que `Buffer`.
app.use(
"/ai/voice",
raw({ type: () => true, limit: "25mb" }),
);
app.enableCors({ origin: true, credentials: false });
await app.listen(config.PORT);
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");

View File

@ -0,0 +1,100 @@
import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Res,
} from "@nestjs/common";
import type { Response } from "express";
import {
CalendarEventCreateDto,
CalendarEventListQuery,
CalendarEventPatchDto,
} from "@ordinarthur-os/shared";
import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "@/config/env";
import { ZodPipe } from "@/lib/zod-pipe";
import { AgendaService } from "./agenda.service";
@Controller("agenda")
export class AgendaController {
constructor(
private readonly agenda: AgendaService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
// ── CRUD événements ────────────────────────────────────────────────────────
@Get("events")
list(@Query(new ZodPipe(CalendarEventListQuery)) query: CalendarEventListQuery) {
return this.agenda.list(query);
}
@Post("events")
create(@Body(new ZodPipe(CalendarEventCreateDto)) dto: CalendarEventCreateDto) {
return this.agenda.create(dto);
}
@Patch("events/:id")
patch(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(CalendarEventPatchDto)) dto: CalendarEventPatchDto,
) {
return this.agenda.patch(id, dto);
}
@Delete("events/:id")
remove(@Param("id", new ParseUUIDPipe()) id: string) {
return this.agenda.remove(id);
}
// ── Google OAuth ───────────────────────────────────────────────────────────
@Get("google/status")
googleStatus() {
return this.agenda.getGoogleStatus();
}
// Retourne { url } au lieu de rediriger : le frontend appelle avec le bearer
// token puis fait window.location.href = url côté navigateur.
@Get("google/oauth/start")
oauthStart(): { url: string } {
return { url: this.agenda.getOAuthUrl() };
}
@Get("google/oauth/callback")
async oauthCallback(@Query("code") code: string, @Res() res: Response) {
await this.agenda.handleOAuthCallback(code);
const pwaUrl = this.agenda.getPwaUrl();
res.redirect(`${pwaUrl}/agenda`);
}
@Post("google/sync")
sync() {
return this.agenda.syncGoogle();
}
// ── iCal feed (endpoint public — secret dans l'URL) ───────────────────────
@Get("ical/:secret")
async ical(
@Param("secret") secret: string,
@Res() res: Response,
) {
const expected = this.config.ICAL_FEED_SECRET;
if (!expected || secret !== expected) {
res.status(403).send("Forbidden");
return;
}
const ical = await this.agenda.buildIcal();
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="ordinarthur-os.ics"');
res.send(ical);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AgendaController } from "./agenda.controller";
import { AgendaService } from "./agenda.service";
@Module({
controllers: [AgendaController],
providers: [AgendaService],
})
export class AgendaModule {}

View File

@ -0,0 +1,373 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { and, eq, gte, lte, sql } from "drizzle-orm";
import { schema } from "@ordinarthur-os/db";
import type {
CalendarEvent,
CalendarEventCreateDto,
CalendarEventListQuery,
CalendarEventPatchDto,
GoogleSyncResponse,
} from "@ordinarthur-os/shared";
import { InjectDb, type Db } from "@/db/db.module";
import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "@/config/env";
const { calendarEvents, googleOAuthTokens } = schema;
// ---------------------------------------------------------------------------
// Types Google Calendar REST API (subset)
// ---------------------------------------------------------------------------
interface GoogleTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}
interface GoogleCalendarEvent {
id: string;
summary?: string;
description?: string;
location?: string;
start: { dateTime?: string; date?: string };
end: { dateTime?: string; date?: string };
status?: string;
}
@Injectable()
export class AgendaService {
constructor(
@InjectDb() private readonly db: Db,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
async list(query: CalendarEventListQuery): Promise<CalendarEvent[]> {
const conditions = [];
if (query.from) conditions.push(gte(calendarEvents.startsAt, new Date(query.from)));
if (query.to) conditions.push(lte(calendarEvents.endsAt, new Date(query.to)));
const rows = await this.db
.select()
.from(calendarEvents)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(calendarEvents.startsAt);
return rows.map(rowToDto);
}
async create(dto: CalendarEventCreateDto): Promise<CalendarEvent> {
const [row] = await this.db
.insert(calendarEvents)
.values({
title: dto.title,
description: dto.description ?? null,
location: dto.location ?? null,
startsAt: new Date(dto.starts_at),
endsAt: new Date(dto.ends_at),
allDay: dto.all_day ?? false,
source: "ordinarthur-os",
})
.returning();
if (!row) throw new Error("Insert returned no row");
return rowToDto(row);
}
async patch(id: string, dto: CalendarEventPatchDto): Promise<CalendarEvent> {
const set: Partial<typeof calendarEvents.$inferInsert> = {
updatedAt: new Date(),
};
if (dto.title !== undefined) set.title = dto.title;
if (dto.description !== undefined) set.description = dto.description ?? null;
if (dto.location !== undefined) set.location = dto.location ?? null;
if (dto.starts_at !== undefined) set.startsAt = new Date(dto.starts_at);
if (dto.ends_at !== undefined) set.endsAt = new Date(dto.ends_at);
if (dto.all_day !== undefined) set.allDay = dto.all_day;
const [row] = await this.db
.update(calendarEvents)
.set(set)
.where(eq(calendarEvents.id, id))
.returning();
if (!row) throw new NotFoundException(`CalendarEvent ${id} not found`);
return rowToDto(row);
}
async remove(id: string): Promise<void> {
await this.db.delete(calendarEvents).where(eq(calendarEvents.id, id));
}
// ---------------------------------------------------------------------------
// Google OAuth
// ---------------------------------------------------------------------------
async getGoogleStatus(): Promise<{ connected: boolean }> {
const [token] = await this.db.select().from(googleOAuthTokens);
return { connected: !!token?.refreshToken };
}
getPwaUrl(): string {
return this.config.PWA_URL;
}
getOAuthUrl(): string {
const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_REDIRECT_URI } = this.config;
if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_REDIRECT_URI) {
throw new Error("Google OAuth not configured");
}
const params = new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT_ID,
redirect_uri: GOOGLE_OAUTH_REDIRECT_URI,
response_type: "code",
scope: "https://www.googleapis.com/auth/calendar.readonly",
access_type: "offline",
prompt: "consent",
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
async handleOAuthCallback(code: string): Promise<void> {
const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, GOOGLE_OAUTH_REDIRECT_URI } =
this.config;
if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_CLIENT_SECRET || !GOOGLE_OAUTH_REDIRECT_URI) {
throw new Error("Google OAuth not configured");
}
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: GOOGLE_OAUTH_CLIENT_ID,
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
redirect_uri: GOOGLE_OAUTH_REDIRECT_URI,
grant_type: "authorization_code",
}),
});
if (!res.ok) throw new Error(`Google token exchange failed: ${res.statusText}`);
const tokens = (await res.json()) as GoogleTokenResponse;
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
await this.db
.insert(googleOAuthTokens)
.values({
id: 1,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? "",
expiresAt,
})
.onConflictDoUpdate({
target: googleOAuthTokens.id,
set: {
accessToken: tokens.access_token,
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
expiresAt,
},
});
}
// ---------------------------------------------------------------------------
// Sync Google Calendar → local DB
// ---------------------------------------------------------------------------
async syncGoogle(): Promise<GoogleSyncResponse> {
const accessToken = await this.getValidAccessToken();
if (!accessToken) throw new Error("Google OAuth not connected");
const [tokenRow] = await this.db.select().from(googleOAuthTokens);
const calendarId = tokenRow?.calendarId ?? "primary";
const from = new Date();
from.setDate(from.getDate() - 7);
const to = new Date();
to.setDate(to.getDate() + 60);
const params = new URLSearchParams({
timeMin: from.toISOString(),
timeMax: to.toISOString(),
singleEvents: "true",
orderBy: "startTime",
maxResults: "250",
});
const res = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events?${params}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!res.ok) throw new Error(`Google Calendar API error: ${res.statusText}`);
const data = (await res.json()) as { items: GoogleCalendarEvent[] };
let inserted = 0;
let updated = 0;
for (const ev of data.items ?? []) {
if (ev.status === "cancelled") continue;
const allDay = !ev.start.dateTime;
const startsAt = new Date(ev.start.dateTime ?? ev.start.date ?? "");
const endsAt = new Date(ev.end.dateTime ?? ev.end.date ?? "");
if (isNaN(startsAt.getTime()) || isNaN(endsAt.getTime())) continue;
const existing = await this.db
.select({ id: calendarEvents.id })
.from(calendarEvents)
.where(eq(calendarEvents.googleEventId, ev.id));
if (existing.length) {
await this.db
.update(calendarEvents)
.set({
title: ev.summary ?? "(sans titre)",
description: ev.description ?? null,
location: ev.location ?? null,
startsAt,
endsAt,
allDay,
updatedAt: new Date(),
})
.where(eq(calendarEvents.googleEventId, ev.id));
updated++;
} else {
await this.db.insert(calendarEvents).values({
googleEventId: ev.id,
title: ev.summary ?? "(sans titre)",
description: ev.description ?? null,
location: ev.location ?? null,
startsAt,
endsAt,
allDay,
source: "google",
});
inserted++;
}
}
return { inserted, updated };
}
// ---------------------------------------------------------------------------
// iCal feed
// ---------------------------------------------------------------------------
async buildIcal(): Promise<string> {
const from = new Date();
from.setDate(from.getDate() - 30);
const to = new Date();
to.setDate(to.getDate() + 180);
const rows = await this.db
.select()
.from(calendarEvents)
.where(and(gte(calendarEvents.startsAt, from), lte(calendarEvents.endsAt, to)))
.orderBy(calendarEvents.startsAt);
const vevents = rows.map((row) => {
const uid = `${row.id}@ordinarthur-os`;
const dtstart = row.allDay
? formatIcalDate(row.startsAt)
: `${formatIcalDateTime(row.startsAt)}`;
const dtend = row.allDay
? formatIcalDate(row.endsAt)
: `${formatIcalDateTime(row.endsAt)}`;
const dtstartProp = row.allDay ? `DTSTART;VALUE=DATE:${dtstart}` : `DTSTART:${dtstart}`;
const dtendProp = row.allDay ? `DTEND;VALUE=DATE:${dtend}` : `DTEND:${dtend}`;
const lines = [
"BEGIN:VEVENT",
`UID:${uid}`,
dtstartProp,
dtendProp,
`SUMMARY:${escapeIcal(row.title)}`,
row.description ? `DESCRIPTION:${escapeIcal(row.description)}` : null,
row.location ? `LOCATION:${escapeIcal(row.location)}` : null,
`DTSTAMP:${formatIcalDateTime(row.updatedAt)}`,
"END:VEVENT",
];
return lines.filter(Boolean).join("\r\n");
});
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//ordinarthur-os//ordinarthur-os//FR",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:ordinarthur-os",
...vevents,
"END:VCALENDAR",
].join("\r\n");
}
// ---------------------------------------------------------------------------
// Helpers privés
// ---------------------------------------------------------------------------
private async getValidAccessToken(): Promise<string | null> {
const [token] = await this.db.select().from(googleOAuthTokens);
if (!token) return null;
if (token.expiresAt > new Date(Date.now() + 60_000)) {
return token.accessToken;
}
const { GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET } = this.config;
if (!GOOGLE_OAUTH_CLIENT_ID || !GOOGLE_OAUTH_CLIENT_SECRET) return null;
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT_ID,
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
refresh_token: token.refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) return null;
const refreshed = (await res.json()) as GoogleTokenResponse;
const expiresAt = new Date(Date.now() + refreshed.expires_in * 1000);
await this.db
.update(googleOAuthTokens)
.set({ accessToken: refreshed.access_token, expiresAt })
.where(eq(googleOAuthTokens.id, 1));
return refreshed.access_token;
}
}
// ---------------------------------------------------------------------------
// Helpers purs
// ---------------------------------------------------------------------------
function rowToDto(row: typeof calendarEvents.$inferSelect): CalendarEvent {
return {
id: row.id,
google_event_id: row.googleEventId ?? null,
title: row.title,
description: row.description ?? null,
location: row.location ?? null,
starts_at: row.startsAt.toISOString(),
ends_at: row.endsAt.toISOString(),
all_day: row.allDay,
source: row.source,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function formatIcalDateTime(d: Date): string {
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function formatIcalDate(d: Date): string {
return d.toISOString().slice(0, 10).replace(/-/g, "");
}
function escapeIcal(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
}

View File

@ -0,0 +1,64 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from "@nestjs/common";
import type { Request } from "express";
import {
AiCommandRequest,
AiCommandResponse,
AiConfirmRequest,
AiConfirmResponse,
AiVoiceResponse,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "@/lib/zod-pipe";
import { AiService } from "./ai.service";
@Controller("ai")
export class AiController {
constructor(private readonly ai: AiService) {}
@Post("command")
command(
@Body(new ZodPipe(AiCommandRequest)) body: AiCommandRequest,
): Promise<AiCommandResponse> {
return this.ai.command(body.text);
}
/**
* POST /ai/voice
* Body brut : le blob audio (WebM/Opus recommandé) la PWA poste avec
* `Content-Type: audio/webm`. Un `express.raw()` est monté pour ce path
* dans `main.ts`.
*/
@Post("voice")
async voice(@Req() req: Request): Promise<AiVoiceResponse> {
const body = req.body;
if (!Buffer.isBuffer(body) || body.length === 0) {
throw new BadRequestException("Audio body manquant (envoie le blob brut)");
}
return this.ai.voice(body);
}
@Patch("actions/:id/args")
@HttpCode(204)
patchArgs(
@Param("id", new ParseUUIDPipe()) id: string,
@Body() body: { args: Record<string, unknown> },
): Promise<void> {
return this.ai.patchArgs(id, body.args);
}
@Post("command/confirm")
confirm(
@Body(new ZodPipe(AiConfirmRequest)) body: AiConfirmRequest,
): Promise<AiConfirmResponse> {
return this.ai.confirm(body.action_ids);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { AiController } from "./ai.controller";
import { AiService } from "./ai.service";
import { MistralClient } from "./mistral.client";
import { GroqClient } from "./groq.client";
import { ProjectsModule } from "@/modules/projects/projects.module";
@Module({
imports: [ProjectsModule],
controllers: [AiController],
providers: [AiService, MistralClient, GroqClient],
})
export class AiModule {}

View File

@ -0,0 +1,251 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { schema } from "@ordinarthur-os/db";
import {
AiActionResult,
AiCommandResponse,
AiConfirmResponse,
AiVoiceResponse,
ProposedAction,
ProposedActionWithId,
TodoCreateDto,
} from "@ordinarthur-os/shared";
import { eq, inArray, sql } from "drizzle-orm";
import { InjectDb, type Db } from "@/db/db.module";
import { ProjectsService } from "@/modules/projects/projects.service";
import { MistralClient } from "./mistral.client";
import { GroqClient } from "./groq.client";
const { aiActions, todos, calendarEvents, dailyCheckins } = schema;
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
constructor(
@InjectDb() private readonly db: Db,
private readonly mistral: MistralClient,
private readonly groq: GroqClient,
private readonly projects: ProjectsService,
) {}
// -------------------------------------------------------------------------
// Endpoints
// -------------------------------------------------------------------------
async command(text: string): Promise<AiCommandResponse> {
const proposals = await this.mistral.proposeActions(text);
const actions = await this.persistProposals(proposals, { inputText: text });
return { actions };
}
async voice(audio: Buffer): Promise<AiVoiceResponse> {
const transcript = await this.groq.transcribe(audio);
if (!transcript) {
return { transcript: "", actions: [] };
}
const proposals = await this.mistral.proposeActions(transcript);
const actions = await this.persistProposals(proposals, { transcript });
return { transcript, actions };
}
async patchArgs(id: string, args: Record<string, unknown>): Promise<void> {
const [row] = await this.db.select().from(aiActions).where(eq(aiActions.id, id));
if (!row || row.status !== "proposed") {
throw new NotFoundException(`Action ${id} introuvable ou déjà traitée`);
}
await this.db
.update(aiActions)
.set({ functionArgs: args })
.where(eq(aiActions.id, id));
}
async confirm(actionIds: string[]): Promise<AiConfirmResponse> {
const rows = await this.db
.select()
.from(aiActions)
.where(inArray(aiActions.id, actionIds));
if (rows.length === 0) {
throw new NotFoundException("Aucune action proposée trouvée");
}
const results: AiActionResult[] = [];
for (const row of rows) {
if (row.status !== "proposed") {
// Idempotent : on ne re-exécute pas une action déjà traitée.
results.push({
id: row.id,
status: row.status === "confirmed" ? "confirmed" : "failed",
error: row.status !== "confirmed" ? `Déjà en status ${row.status}` : undefined,
});
continue;
}
try {
const result = await this.executeAction({
fn: row.functionName as ProposedAction["fn"],
args: row.functionArgs,
} as ProposedAction);
await this.db
.update(aiActions)
.set({ status: "confirmed", result: result as object, confirmedAt: new Date() })
.where(eq(aiActions.id, row.id));
results.push({ id: row.id, status: "confirmed", result });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Exécution action ${row.id} échouée: ${message}`);
await this.db
.update(aiActions)
.set({ status: "failed", error: message, confirmedAt: new Date() })
.where(eq(aiActions.id, row.id));
results.push({ id: row.id, status: "failed", error: message });
}
}
return { results };
}
// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
private async persistProposals(
proposals: Array<{ name: string; args: unknown }>,
source: { inputText?: string; transcript?: string },
): Promise<ProposedActionWithId[]> {
const persisted: ProposedActionWithId[] = [];
for (const { name, args } of proposals) {
// Valide côté serveur avec zod — si Mistral a hallucinne, on log + skip.
const parsed = ProposedAction.safeParse({ fn: name, args });
if (!parsed.success) {
this.logger.warn(
`Action ignorée (validation zod): fn=${name}, raison=${JSON.stringify(parsed.error.flatten())}`,
);
continue;
}
const [row] = await this.db
.insert(aiActions)
.values({
inputText: source.inputText ?? null,
transcript: source.transcript ?? null,
functionName: parsed.data.fn,
functionArgs: parsed.data.args as object,
status: "proposed",
})
.returning();
if (row) {
persisted.push({ id: row.id, action: parsed.data });
}
}
return persisted;
}
/**
* Exécute une action confirmée. Chaque branche doit être idempotente-safe
* (on ne réexécute pas une action déjà confirmée, cf. `confirm`).
*
* Phase 5 : seul `create_todo` est câblé les autres tables (projets,
* agenda, daily_checkins) arrivent en phases suivantes. On lève une erreur
* explicite pour les fonctions non encore implémentées.
*/
private async executeAction(action: ProposedAction): Promise<unknown> {
switch (action.fn) {
case "create_todo": {
const dto: TodoCreateDto = {
title: action.args.title,
description: action.args.description ?? null,
status: "inbox",
priority: action.args.priority ?? null,
due_at: action.args.due_at ?? null,
tags: action.args.tags ?? [],
project_id: action.args.project_id ?? null,
checklist: [],
verification_steps: [],
};
const [row] = await this.db
.insert(todos)
.values({
title: dto.title,
description: dto.description ?? null,
status: dto.status ?? "inbox",
priority: dto.priority ?? null,
dueAt: dto.due_at ? new Date(dto.due_at) : null,
tags: dto.tags ?? [],
projectId: dto.project_id ?? null,
checklist: dto.checklist ?? [],
verificationSteps: dto.verification_steps ?? [],
aiEnriched: true,
})
.returning();
if (!row) throw new Error("Insertion todo: aucune ligne renvoyée");
return { todo_id: row.id };
}
case "capture_idea": {
const idea = await this.projects.createIdea(action.args.project_id ?? null, {
content: action.args.content,
priority: action.args.priority ?? null,
});
return { idea_id: idea.id };
}
case "add_project_idea": {
const idea = await this.projects.createIdea(action.args.project_id, {
content: action.args.content,
});
return { idea_id: idea.id };
}
case "add_project_step": {
const step = await this.projects.createStep(action.args.project_id, {
title: action.args.title,
status: action.args.status ?? "backlog",
});
return { step_id: step.id };
}
case "create_calendar_event": {
const [row] = await this.db
.insert(calendarEvents)
.values({
title: action.args.title,
description: action.args.description ?? null,
location: action.args.location ?? null,
startsAt: new Date(action.args.starts_at),
endsAt: new Date(action.args.ends_at),
allDay: false,
source: "ordinarthur-os",
})
.returning();
if (!row) throw new Error("Insertion calendar_event: aucune ligne renvoyée");
return { event_id: row.id };
}
case "toggle_daily_checkin": {
const today = new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" });
const [row] = await this.db
.insert(dailyCheckins)
.values({ day: today, medsTaken: true, note: action.args.note ?? null })
.onConflictDoUpdate({
target: dailyCheckins.day,
set: {
medsTaken: sql`NOT ${dailyCheckins.medsTaken}`,
note: action.args.note ?? null,
updatedAt: sql`now()`,
},
})
.returning();
if (!row) throw new Error("Upsert daily_checkin: aucune ligne renvoyée");
return { day: row.day, meds_taken: row.medsTaken };
}
}
}
}

View File

@ -0,0 +1,50 @@
import { Inject, Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "@/config/env";
interface GroqTranscriptionResponse {
text: string;
}
@Injectable()
export class GroqClient {
private readonly logger = new Logger(GroqClient.name);
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
/**
* Envoie le blob audio à Groq Whisper, langue forcée à `fr`.
* Accepte WebM/Opus (MediaRecorder par défaut) ou tout autre format supporté.
*/
async transcribe(audio: Buffer, filename = "audio.webm"): Promise<string> {
if (!this.config.GROQ_API_KEY) {
throw new ServiceUnavailableException("GROQ_API_KEY non configurée");
}
const form = new FormData();
// Node 20+ expose un Blob global conforme. On copie dans un ArrayBuffer
// dédié : lib.dom type `Blob` refuse `ArrayBufferLike` (Buffer sous-jacent).
const copy = new ArrayBuffer(audio.byteLength);
new Uint8Array(copy).set(audio);
form.set("file", new Blob([copy], { type: "audio/webm" }), filename);
form.set("model", this.config.GROQ_STT_MODEL);
form.set("language", "fr");
form.set("response_format", "json");
form.set("temperature", "0");
const res = await fetch("https://api.groq.com/openai/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: `Bearer ${this.config.GROQ_API_KEY}` },
body: form,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
this.logger.error(`Groq ${res.status}: ${body}`);
throw new ServiceUnavailableException("Groq API error");
}
const data = (await res.json()) as GroqTranscriptionResponse;
return data.text.trim();
}
}

View File

@ -0,0 +1,235 @@
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "@/config/env";
import { Inject } from "@nestjs/common";
/**
* Fonctions exposées à Mistral. Doivent matcher exactement le schéma
* `ProposedAction` de `@ordinarthur-os/shared` la validation zod se fait
* côté AiService.
*/
export const MISTRAL_TOOLS = [
{
type: "function" as const,
function: {
name: "create_todo",
description:
"Crée une tâche dans l'inbox d'Arthur. Utilise cette fonction dès qu'Arthur parle d'un truc à faire, relancer, envoyer, préparer, etc.",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "Titre court de la tâche (impératif)" },
description: { type: "string", description: "Détail en markdown si pertinent" },
due_at: {
type: "string",
format: "date-time",
description: "Échéance ISO 8601 (UTC). À déduire d'expressions comme 'demain', 'vendredi'.",
},
priority: {
type: "integer",
minimum: 0,
maximum: 3,
description: "0 = bas, 3 = urgent. Défaut 1.",
},
project_id: { type: "string", format: "uuid" },
tags: { type: "array", items: { type: "string" } },
},
required: ["title"],
},
},
},
{
type: "function" as const,
function: {
name: "add_project_idea",
description: "Ajoute une idée à un projet existant.",
parameters: {
type: "object",
properties: {
project_id: { type: "string", format: "uuid" },
content: { type: "string" },
},
required: ["project_id", "content"],
},
},
},
{
type: "function" as const,
function: {
name: "add_project_step",
description: "Ajoute une étape (carte kanban) à un projet.",
parameters: {
type: "object",
properties: {
project_id: { type: "string", format: "uuid" },
title: { type: "string" },
status: {
type: "string",
enum: ["backlog", "todo", "doing", "review", "done"],
},
},
required: ["project_id", "title"],
},
},
},
{
type: "function" as const,
function: {
name: "create_calendar_event",
description: "Crée un événement dans l'agenda d'Arthur.",
parameters: {
type: "object",
properties: {
title: { type: "string" },
starts_at: { type: "string", format: "date-time" },
ends_at: { type: "string", format: "date-time" },
location: { type: "string" },
description: { type: "string" },
},
required: ["title", "starts_at", "ends_at"],
},
},
},
{
type: "function" as const,
function: {
name: "toggle_daily_checkin",
description:
"Marque le check-in quotidien d'Arthur (médocs pris, état du jour). À utiliser sur 'j'ai pris mes médocs', 'check in'.",
parameters: {
type: "object",
properties: {
note: { type: "string" },
},
},
},
},
{
type: "function" as const,
function: {
name: "capture_idea",
description:
"Capture une idée, une piste, une réflexion, un concept à retenir — sans que ce soit une tâche à faire. Utilise cette fonction quand Arthur parle d'une idée, d'un concept, d'une réflexion, d'une chose qui lui vient à l'esprit mais qui n'est pas un TODO concret. Exemple : 'j'ai eu l'idée de...', 'ce serait bien si...', 'idée :', 'je pensais à...'.",
parameters: {
type: "object",
properties: {
content: {
type: "string",
description: "L'idée, telle que formulée ou reformulée de façon concise.",
},
priority: {
type: "integer",
minimum: 0,
maximum: 3,
description: "0 = basse, 3 = urgente. Défaut 1.",
},
project_id: {
type: "string",
format: "uuid",
description: "UUID du projet concerné, si l'idée s'y rattache clairement.",
},
},
required: ["content"],
},
},
},
];
interface MistralToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
interface MistralResponse {
choices: Array<{
message: {
role: string;
content: string | null;
tool_calls?: MistralToolCall[];
};
}>;
}
function isDST(): boolean {
const jan = new Date(new Date().getFullYear(), 0, 1).getTimezoneOffset();
const jul = new Date(new Date().getFullYear(), 6, 1).getTimezoneOffset();
return new Date().getTimezoneOffset() < Math.max(jan, jul);
}
const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os.
- Réponds UNIQUEMENT en français.
- Ton rôle est d'interpréter la demande et d'appeler 0, 1 ou plusieurs fonctions pour capturer son intention.
- Tu ne poses pas de questions : si une info manque, tu proposes un titre court et laisses les champs optionnels vides.
- Si la demande ne matche aucune fonction (ex : question / discussion), n'appelle aucun outil.
- N'exécute rien toi-même : tu ne fais que proposer. L'utilisateur confirmera côté UI.
## Distinction TODO vs IDÉE règle fondamentale
- **create_todo** = quelque chose à FAIRE, une action concrète. Verbes d'action : "je dois", "je vais", "il faut que", "pense à", "relancer", "envoyer", "préparer", "acheter", "appeler".
- **capture_idea** = quelque chose à RETENIR, une réflexion, un concept, une piste. Formulations : "idée de", "ce serait bien si", "j'ai pensé à", "et si on faisait", "idée :", "je pensais à", "idée que", "feature", "concept", "réflexion".
- En cas de doute : si ce n'est pas une action concrète avec un exécutant (Arthur), c'est une idée capture_idea.
## Autres règles
- Formulations déclaratives d'agenda : "j'ai rendez-vous", "je dois aller", "j'ai un rdv", "dans mon agenda" create_calendar_event.
- "j'ai pris mes médocs", "médicaments pris" toggle_daily_checkin.
- Pour create_calendar_event : si la durée n'est pas précisée, suppose 1h.
- Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 avec l'offset Europe/Paris (+02:00 en été, +01:00 en hiver). Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`;
@Injectable()
export class MistralClient {
private readonly logger = new Logger(MistralClient.name);
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
async proposeActions(userText: string): Promise<Array<{ name: string; args: unknown }>> {
if (!this.config.MISTRAL_API_KEY) {
throw new ServiceUnavailableException("MISTRAL_API_KEY non configurée");
}
const now = new Date().toLocaleString("sv-SE", {
timeZone: "Europe/Paris",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
}).replace(" ", "T") + (isDST() ? "+02:00" : "+01:00");
const res = await fetch("https://api.mistral.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.MISTRAL_API_KEY}`,
},
body: JSON.stringify({
model: this.config.MISTRAL_MODEL,
temperature: 0.2,
messages: [
{ role: "system", content: `${SYSTEM_PROMPT}\n\nHeure courante : ${now}` },
{ role: "user", content: userText },
],
tools: MISTRAL_TOOLS,
tool_choice: "auto",
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
this.logger.error(`Mistral ${res.status}: ${body}`);
throw new ServiceUnavailableException("Mistral API error");
}
const data = (await res.json()) as MistralResponse;
const message = data.choices[0]?.message;
const toolCalls = message?.tool_calls ?? [];
return toolCalls.map((call) => {
let parsed: unknown = {};
try {
parsed = JSON.parse(call.function.arguments);
} catch (err) {
this.logger.warn(`Mistral a renvoyé des args non-JSON: ${call.function.arguments}`);
}
return { name: call.function.name, args: parsed };
});
}
}

View File

@ -1,7 +1,7 @@
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";
import { APP_CONFIG } from "@/config/config.module";
import type { AppConfig } from "@/config/env";
@Injectable()
export class BearerMiddleware implements NestMiddleware {

View File

@ -0,0 +1,21 @@
import { Body, Controller, Get, Post } from "@nestjs/common";
import { DailyCheckinToggleRequest } from "@ordinarthur-os/shared";
import { ZodPipe } from "@/lib/zod-pipe";
import { HealthTabService } from "./health-tab.service";
@Controller("health-tab")
export class HealthTabController {
constructor(private readonly health: HealthTabService) {}
@Get("today")
today() {
return this.health.today();
}
@Post("today/toggle")
toggle(
@Body(new ZodPipe(DailyCheckinToggleRequest)) body: DailyCheckinToggleRequest,
) {
return this.health.toggle(body);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { HealthTabController } from "./health-tab.controller";
import { HealthTabService } from "./health-tab.service";
@Module({
controllers: [HealthTabController],
providers: [HealthTabService],
})
export class HealthTabModule {}

View File

@ -0,0 +1,80 @@
import { Injectable } from "@nestjs/common";
import { schema } from "@ordinarthur-os/db";
import type { DailyCheckin, DailyCheckinToggleRequest } from "@ordinarthur-os/shared";
import { eq } from "drizzle-orm";
import { InjectDb, type Db } from "@/db/db.module";
const { dailyCheckins } = schema;
/**
* `day` est stocké en `date` (pas timestamptz) la clé de regroupement
* doit donc être calculée dans le fuseau d'Arthur (Europe/Paris) pour que
* "aujourd'hui" ne flippe pas à 02h du matin UTC.
*/
function todayInParis(): string {
// fr-CA → YYYY-MM-DD
return new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" });
}
@Injectable()
export class HealthTabService {
constructor(@InjectDb() private readonly db: Db) {}
async today(): Promise<DailyCheckin> {
const day = todayInParis();
const [row] = await this.db
.select()
.from(dailyCheckins)
.where(eq(dailyCheckins.day, day));
if (row) return rowToCheckin(row);
// Pas encore de ligne → renvoyer l'état par défaut, sans insérer
// tant qu'Arthur ne coche rien.
return {
day,
meds_taken: false,
note: null,
updated_at: new Date().toISOString(),
};
}
/**
* Upsert sur la ligne du jour. Sans `meds_taken` explicite, flip la valeur.
*/
async toggle(patch: DailyCheckinToggleRequest): Promise<DailyCheckin> {
const day = todayInParis();
const existing = await this.db
.select()
.from(dailyCheckins)
.where(eq(dailyCheckins.day, day));
const currentMeds = existing[0]?.medsTaken ?? false;
const currentNote = existing[0]?.note ?? null;
const nextMeds = patch.meds_taken ?? !currentMeds;
const nextNote = patch.note !== undefined ? patch.note : currentNote;
const now = new Date();
const [row] = await this.db
.insert(dailyCheckins)
.values({ day, medsTaken: nextMeds, note: nextNote, updatedAt: now })
.onConflictDoUpdate({
target: dailyCheckins.day,
set: { medsTaken: nextMeds, note: nextNote, updatedAt: now },
})
.returning();
if (!row) throw new Error("daily_checkin upsert returned no row");
return rowToCheckin(row);
}
}
function rowToCheckin(row: typeof dailyCheckins.$inferSelect): DailyCheckin {
return {
day: row.day,
meds_taken: row.medsTaken,
note: row.note ?? null,
updated_at: row.updatedAt.toISOString(),
};
}

View File

@ -16,7 +16,7 @@ import {
JobPatchDto,
JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "../../lib/zod-pipe";
import { ZodPipe } from "@/lib/zod-pipe";
import { JobsService } from "./jobs.service";
@Controller("jobs")

View File

@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { schema } from "@ordinarthur-os/db";
import {
Job,
JobIngestDto,
@ -8,61 +9,60 @@ import {
JobSearchCriteria,
JobSearchCriteriaUpsert,
} from "@ordinarthur-os/shared";
import { InjectSupabase, type Supabase } from "../../db/db.module";
import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
import { InjectDb, type Db } from "@/db/db.module";
const { jobs, jobSearchCriteria } = schema;
const RETENTION_DAYS = 30;
@Injectable()
export class JobsService {
constructor(@InjectSupabase() private readonly db: Supabase) {}
constructor(@InjectDb() private readonly db: Db) {}
async ingest(jobs: JobIngestDto[]): Promise<JobIngestResponse> {
// ---- ingest / list / patch --------------------------------------------
async ingest(input: JobIngestDto[]): Promise<JobIngestResponse> {
let inserted = 0;
let updated = 0;
// On traite job par job pour distinguer insert vs update.
// Volume attendu : ~quelques dizaines/jour, c'est OK.
for (const j of jobs) {
const { data: existing } = await this.db
.from("jobs")
.select("id")
.eq("source_url", j.source_url)
.maybeSingle();
if (existing) {
await this.db
.from("jobs")
.update({
title: j.title,
company: j.company ?? null,
description: j.description ?? null,
location: j.location ?? null,
remote_type: j.remote_type ?? null,
salary_min: j.salary_min ?? null,
salary_max: j.salary_max ?? null,
stack: j.stack ?? [],
apply_url: j.apply_url ?? null,
last_seen_at: new Date().toISOString(),
})
.eq("id", existing.id);
updated++;
} else {
const { error } = await this.db.from("jobs").insert({
// Upsert sur `source_url` (clé de dedup). On insère et, en cas de conflit,
// on met à jour les champs mutables + bump `last_seen_at`. `xmax` permet
// de savoir si la ligne résulte d'un INSERT (xmax=0) ou d'un UPDATE (xmax≠0).
for (const j of input) {
const rows = await this.db
.insert(jobs)
.values({
source: j.source,
source_url: j.source_url,
sourceUrl: j.source_url,
title: j.title,
company: j.company ?? null,
description: j.description ?? null,
location: j.location ?? null,
remote_type: j.remote_type ?? null,
salary_min: j.salary_min ?? null,
salary_max: j.salary_max ?? null,
remoteType: j.remote_type ?? null,
salaryMin: j.salary_min ?? null,
salaryMax: j.salary_max ?? null,
stack: j.stack ?? [],
apply_url: j.apply_url ?? null,
});
if (error) throw error;
inserted++;
}
applyUrl: j.apply_url ?? null,
})
.onConflictDoUpdate({
target: jobs.sourceUrl,
set: {
title: j.title,
company: j.company ?? null,
description: j.description ?? null,
location: j.location ?? null,
remoteType: j.remote_type ?? null,
salaryMin: j.salary_min ?? null,
salaryMax: j.salary_max ?? null,
stack: j.stack ?? [],
applyUrl: j.apply_url ?? null,
lastSeenAt: new Date(),
},
})
.returning({ wasUpdate: sql<boolean>`xmax <> 0` });
if (rows[0]?.wasUpdate) updated++;
else inserted++;
}
const archived = await this.archiveStale();
@ -71,79 +71,147 @@ export class JobsService {
/** Soft-delete des jobs non revus depuis RETENTION_DAYS jours. */
private async archiveStale(): Promise<number> {
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000).toISOString();
const { data, error } = await this.db
.from("jobs")
.update({ archived: true })
.lt("last_seen_at", cutoff)
.eq("archived", false)
.select("id");
if (error) throw error;
return data?.length ?? 0;
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000);
const rows = await this.db
.update(jobs)
.set({ archived: true })
.where(and(lt(jobs.lastSeenAt, cutoff), eq(jobs.archived, false)))
.returning({ id: jobs.id });
return rows.length;
}
async list(q: JobListQuery): Promise<Job[]> {
let query = this.db.from("jobs").select("*").order("last_seen_at", { ascending: false });
const filters = [eq(jobs.archived, q.archived ?? false)];
if (typeof q.archived === "boolean") query = query.eq("archived", q.archived);
else query = query.eq("archived", false); // défaut : non archivés
if (q.remote_type) filters.push(eq(jobs.remoteType, q.remote_type));
if (q.starred !== undefined) filters.push(eq(jobs.starred, q.starred));
if (q.since) filters.push(gte(jobs.lastSeenAt, new Date(q.since)));
if (q.stack?.length) filters.push(arrayOverlaps(jobs.stack, q.stack));
if (q.remote_type) query = query.eq("remote_type", q.remote_type);
if (q.starred !== undefined) query = query.eq("starred", q.starred);
if (q.since) query = query.gte("last_seen_at", q.since);
if (q.stack?.length) query = query.overlaps("stack", q.stack);
const rows = await this.db
.select()
.from(jobs)
.where(and(...filters))
.orderBy(desc(jobs.lastSeenAt))
.limit(500);
const { data, error } = await query.limit(500);
if (error) throw error;
return (data ?? []) as Job[];
return rows.map(toJobDto);
}
async patch(id: string, patch: JobPatchDto): Promise<Job> {
const { data, error } = await this.db
.from("jobs")
.update(patch)
.eq("id", id)
.select("*")
.maybeSingle();
if (error) throw error;
if (!data) throw new NotFoundException(`Job ${id} not found`);
return data as Job;
const rows = await this.db
.update(jobs)
.set({
...(patch.starred !== undefined && { starred: patch.starred }),
...(patch.archived !== undefined && { archived: patch.archived }),
...(patch.applied_at !== undefined && {
appliedAt: patch.applied_at === null ? null : new Date(patch.applied_at),
}),
...(patch.notes !== undefined && { notes: patch.notes }),
})
.where(eq(jobs.id, id))
.returning();
const row = rows[0];
if (!row) throw new NotFoundException(`Job ${id} not found`);
return toJobDto(row);
}
// ---- criteria ----------------------------------------------------------
async listCriteria(activeOnly = false): Promise<JobSearchCriteria[]> {
let q = this.db.from("job_search_criteria").select("*").order("created_at", { ascending: true });
if (activeOnly) q = q.eq("active", true);
const { data, error } = await q;
if (error) throw error;
return (data ?? []) as JobSearchCriteria[];
const rows = await (activeOnly
? this.db
.select()
.from(jobSearchCriteria)
.where(eq(jobSearchCriteria.active, true))
.orderBy(asc(jobSearchCriteria.createdAt))
: this.db.select().from(jobSearchCriteria).orderBy(asc(jobSearchCriteria.createdAt)));
return rows.map(toCriteriaDto);
}
async createCriteria(input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
const { data, error } = await this.db
.from("job_search_criteria")
.insert(input)
.select("*")
.single();
if (error) throw error;
return data as JobSearchCriteria;
const rows = await this.db
.insert(jobSearchCriteria)
.values({
name: input.name ?? null,
titles: input.titles,
locations: input.locations,
stack: input.stack,
remoteTypes: input.remote_types,
salaryMin: input.salary_min ?? null,
active: input.active,
})
.returning();
return toCriteriaDto(rows[0]!);
}
async updateCriteria(id: string, input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
const { data, error } = await this.db
.from("job_search_criteria")
.update({ ...input, updated_at: new Date().toISOString() })
.eq("id", id)
.select("*")
.maybeSingle();
if (error) throw error;
if (!data) throw new NotFoundException(`Criteria ${id} not found`);
return data as JobSearchCriteria;
const rows = await this.db
.update(jobSearchCriteria)
.set({
name: input.name ?? null,
titles: input.titles,
locations: input.locations,
stack: input.stack,
remoteTypes: input.remote_types,
salaryMin: input.salary_min ?? null,
active: input.active,
updatedAt: new Date(),
})
.where(eq(jobSearchCriteria.id, id))
.returning();
const row = rows[0];
if (!row) throw new NotFoundException(`Criteria ${id} not found`);
return toCriteriaDto(row);
}
async deleteCriteria(id: string): Promise<void> {
const { error } = await this.db.from("job_search_criteria").delete().eq("id", id);
if (error) throw error;
await this.db.delete(jobSearchCriteria).where(eq(jobSearchCriteria.id, id));
}
}
// ---------------------------------------------------------------------------
// Mapping row (snake_case en DB, camelCase en drizzle) → DTO (snake_case
// côté API — la PWA consomme telle quelle).
// ---------------------------------------------------------------------------
function toJobDto(row: schema.JobRow): Job {
return {
id: row.id,
source: row.source,
source_url: row.sourceUrl,
title: row.title,
company: row.company,
description: row.description,
location: row.location,
remote_type: row.remoteType as Job["remote_type"],
salary_min: row.salaryMin,
salary_max: row.salaryMax,
stack: row.stack,
apply_url: row.applyUrl,
first_seen_at: row.firstSeenAt.toISOString(),
last_seen_at: row.lastSeenAt.toISOString(),
archived: row.archived,
starred: row.starred,
applied_at: row.appliedAt?.toISOString() ?? null,
notes: row.notes,
};
}
function toCriteriaDto(row: schema.JobSearchCriteriaRow): JobSearchCriteria {
return {
id: row.id,
name: row.name,
titles: row.titles,
locations: row.locations,
stack: row.stack,
remote_types: row.remoteTypes as JobSearchCriteria["remote_types"],
salary_min: row.salaryMin,
active: row.active,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}

View File

@ -0,0 +1,136 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from "@nestjs/common";
import {
ProjectCreateDto,
ProjectIdeaCreateDto,
ProjectIdeaPatchDto,
ProjectPatchDto,
ProjectStepCreateDto,
ProjectStepPatchDto,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "@/lib/zod-pipe";
import { ProjectsService } from "./projects.service";
@Controller("projects")
export class ProjectsController {
constructor(private readonly svc: ProjectsService) {}
// ── Routes littérales en premier (avant les routes paramétrées /:id) ─────────
@Get("ideas")
listFreeIdeas() {
return this.svc.listFreeIdeas();
}
@Delete("ideas/:ideaId")
@HttpCode(204)
deleteFreeIdea(@Param("ideaId", new ParseUUIDPipe()) ideaId: string) {
return this.svc.deleteIdea(ideaId);
}
// ── Projects ────────────────────────────────────────────────────────────────
@Get()
listProjects() {
return this.svc.listProjects();
}
@Post()
createProject(@Body(new ZodPipe(ProjectCreateDto)) dto: ProjectCreateDto) {
return this.svc.createProject(dto);
}
@Get(":id")
getProject(@Param("id", new ParseUUIDPipe()) id: string) {
return this.svc.getProject(id);
}
@Patch(":id")
patchProject(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(ProjectPatchDto)) dto: ProjectPatchDto,
) {
return this.svc.patchProject(id, dto);
}
@Delete(":id")
@HttpCode(204)
deleteProject(@Param("id", new ParseUUIDPipe()) id: string) {
return this.svc.deleteProject(id);
}
// ── Steps ───────────────────────────────────────────────────────────────────
@Get(":id/steps")
listSteps(@Param("id", new ParseUUIDPipe()) id: string) {
return this.svc.listSteps(id);
}
@Post(":id/steps")
createStep(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(ProjectStepCreateDto)) dto: ProjectStepCreateDto,
) {
return this.svc.createStep(id, dto);
}
@Patch(":id/steps/:stepId")
patchStep(
@Param("id", new ParseUUIDPipe()) id: string,
@Param("stepId", new ParseUUIDPipe()) stepId: string,
@Body(new ZodPipe(ProjectStepPatchDto)) dto: ProjectStepPatchDto,
) {
return this.svc.patchStep(id, stepId, dto);
}
@Delete(":id/steps/:stepId")
@HttpCode(204)
deleteStep(
@Param("id", new ParseUUIDPipe()) _id: string,
@Param("stepId", new ParseUUIDPipe()) stepId: string,
) {
return this.svc.deleteStep(stepId);
}
// ── Ideas liées à un projet ─────────────────────────────────────────────────
@Get(":id/ideas")
listIdeas(@Param("id", new ParseUUIDPipe()) id: string) {
return this.svc.listIdeas(id);
}
@Post(":id/ideas")
createIdea(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(ProjectIdeaCreateDto)) dto: ProjectIdeaCreateDto,
) {
return this.svc.createIdea(id, dto);
}
@Patch(":id/ideas/:ideaId")
patchIdea(
@Param("id", new ParseUUIDPipe()) _id: string,
@Param("ideaId", new ParseUUIDPipe()) ideaId: string,
@Body(new ZodPipe(ProjectIdeaPatchDto)) dto: ProjectIdeaPatchDto,
) {
return this.svc.patchIdea(ideaId, dto);
}
@Delete(":id/ideas/:ideaId")
@HttpCode(204)
deleteIdea(
@Param("id", new ParseUUIDPipe()) _id: string,
@Param("ideaId", new ParseUUIDPipe()) ideaId: string,
) {
return this.svc.deleteIdea(ideaId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ProjectsController } from "./projects.controller";
import { ProjectsService } from "./projects.service";
@Module({
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}

View File

@ -0,0 +1,217 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { eq, asc, isNull, sql } from "drizzle-orm";
import { schema } from "@ordinarthur-os/db";
import type {
Project,
ProjectCreateDto,
ProjectIdea,
ProjectIdeaCreateDto,
ProjectIdeaPatchDto,
ProjectPatchDto,
ProjectStep,
ProjectStepCreateDto,
ProjectStepPatchDto,
} from "@ordinarthur-os/shared";
import { InjectDb, type Db } from "@/db/db.module";
const { projects, projectSteps, projectIdeas } = schema;
@Injectable()
export class ProjectsService {
constructor(@InjectDb() private readonly db: Db) {}
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
async listProjects(): Promise<Project[]> {
const rows = await this.db
.select()
.from(projects)
.orderBy(asc(projects.createdAt));
return rows.map(projectToDto);
}
async getProject(id: string): Promise<Project> {
const [row] = await this.db.select().from(projects).where(eq(projects.id, id));
if (!row) throw new NotFoundException(`Project ${id} introuvable`);
return projectToDto(row);
}
async createProject(dto: ProjectCreateDto): Promise<Project> {
const [row] = await this.db
.insert(projects)
.values({
name: dto.name,
description: dto.description ?? null,
status: dto.status ?? "active",
})
.returning();
if (!row) throw new Error("Insertion project: aucune ligne renvoyée");
return projectToDto(row);
}
async patchProject(id: string, dto: ProjectPatchDto): Promise<Project> {
const [row] = await this.db
.update(projects)
.set({
...(dto.name !== undefined && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description ?? null }),
...(dto.status !== undefined && { status: dto.status }),
updatedAt: new Date(),
})
.where(eq(projects.id, id))
.returning();
if (!row) throw new NotFoundException(`Project ${id} introuvable`);
return projectToDto(row);
}
async deleteProject(id: string): Promise<void> {
const result = await this.db.delete(projects).where(eq(projects.id, id));
if (result.count === 0) throw new NotFoundException(`Project ${id} introuvable`);
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async listSteps(projectId: string): Promise<ProjectStep[]> {
const rows = await this.db
.select()
.from(projectSteps)
.where(eq(projectSteps.projectId, projectId))
.orderBy(asc(projectSteps.position));
return rows.map(stepToDto);
}
async createStep(projectId: string, dto: ProjectStepCreateDto): Promise<ProjectStep> {
// Position = max existant dans la colonne + 1000
const existing = await this.db
.select()
.from(projectSteps)
.where(eq(projectSteps.projectId, projectId));
const colSteps = existing.filter((s) => s.status === (dto.status ?? "backlog"));
const maxPos = colSteps.length > 0 ? Math.max(...colSteps.map((s) => s.position)) : -1000;
const position = dto.position ?? maxPos + 1000;
const [row] = await this.db
.insert(projectSteps)
.values({
projectId,
title: dto.title,
status: dto.status ?? "backlog",
position,
})
.returning();
if (!row) throw new Error("Insertion step: aucune ligne renvoyée");
return stepToDto(row);
}
async patchStep(projectId: string, stepId: string, dto: ProjectStepPatchDto): Promise<ProjectStep> {
const [row] = await this.db
.update(projectSteps)
.set({
...(dto.title !== undefined && { title: dto.title }),
...(dto.status !== undefined && { status: dto.status }),
...(dto.position !== undefined && { position: dto.position }),
updatedAt: new Date(),
})
.where(eq(projectSteps.id, stepId))
.returning();
if (!row) throw new NotFoundException(`Step ${stepId} introuvable`);
return stepToDto(row);
}
async deleteStep(stepId: string): Promise<void> {
await this.db.delete(projectSteps).where(eq(projectSteps.id, stepId));
}
// ---------------------------------------------------------------------------
// Ideas
// ---------------------------------------------------------------------------
async listFreeIdeas(): Promise<ProjectIdea[]> {
const rows = await this.db
.select()
.from(projectIdeas)
.where(isNull(projectIdeas.projectId))
.orderBy(asc(projectIdeas.createdAt));
return rows.map(ideaToDto);
}
async listIdeas(projectId: string): Promise<ProjectIdea[]> {
const rows = await this.db
.select()
.from(projectIdeas)
.where(eq(projectIdeas.projectId, projectId))
.orderBy(asc(projectIdeas.createdAt));
return rows.map(ideaToDto);
}
async createIdea(projectId: string | null, dto: ProjectIdeaCreateDto): Promise<ProjectIdea> {
const [row] = await this.db
.insert(projectIdeas)
.values({
projectId: projectId ?? dto.project_id ?? null,
content: dto.content,
priority: dto.priority ?? null,
})
.returning();
if (!row) throw new Error("Insertion idea: aucune ligne renvoyée");
return ideaToDto(row);
}
async patchIdea(ideaId: string, dto: ProjectIdeaPatchDto): Promise<ProjectIdea> {
const [row] = await this.db
.update(projectIdeas)
.set({
...(dto.content !== undefined && { content: dto.content }),
...(dto.priority !== undefined && { priority: dto.priority ?? null }),
})
.where(eq(projectIdeas.id, ideaId))
.returning();
if (!row) throw new NotFoundException(`Idea ${ideaId} introuvable`);
return ideaToDto(row);
}
async deleteIdea(ideaId: string): Promise<void> {
await this.db.delete(projectIdeas).where(eq(projectIdeas.id, ideaId));
}
}
// ---------------------------------------------------------------------------
// Mappers
// ---------------------------------------------------------------------------
function projectToDto(row: typeof projects.$inferSelect): Project {
return {
id: row.id,
name: row.name,
description: row.description ?? null,
status: row.status as Project["status"],
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function stepToDto(row: typeof projectSteps.$inferSelect): ProjectStep {
return {
id: row.id,
project_id: row.projectId,
title: row.title,
status: row.status as ProjectStep["status"],
position: row.position,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
};
}
function ideaToDto(row: typeof projectIdeas.$inferSelect): ProjectIdea {
return {
id: row.id,
project_id: row.projectId ?? null,
content: row.content,
priority: row.priority ?? null,
created_at: row.createdAt.toISOString(),
};
}

View File

@ -0,0 +1,60 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
TodoAiEnrichApplyRequest,
TodoCreateDto,
TodoListQuery,
TodoPatchDto,
} from "@ordinarthur-os/shared";
import { ZodPipe } from "@/lib/zod-pipe";
import { TodosService } from "./todos.service";
@Controller("todos")
export class TodosController {
constructor(private readonly todos: TodosService) {}
@Get()
list(@Query(new ZodPipe(TodoListQuery)) query: TodoListQuery) {
return this.todos.list(query);
}
@Post()
create(@Body(new ZodPipe(TodoCreateDto)) input: TodoCreateDto) {
return this.todos.create(input);
}
@Patch(":id")
patch(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(TodoPatchDto)) patch: TodoPatchDto,
) {
return this.todos.patch(id, patch);
}
@Delete(":id")
remove(@Param("id", new ParseUUIDPipe()) id: string) {
return this.todos.remove(id);
}
@Post(":id/ai-enrich")
aiEnrich(@Param("id", new ParseUUIDPipe()) id: string) {
return this.todos.aiEnrich(id);
}
@Post(":id/ai-enrich/apply")
applyAiEnrich(
@Param("id", new ParseUUIDPipe()) id: string,
@Body(new ZodPipe(TodoAiEnrichApplyRequest)) body: TodoAiEnrichApplyRequest,
) {
return this.todos.applyAiEnrich(id, body.draft);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { TodosController } from "./todos.controller";
import { TodosService } from "./todos.service";
@Module({
controllers: [TodosController],
providers: [TodosService],
})
export class TodosModule {}

View File

@ -0,0 +1,209 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { schema } from "@ordinarthur-os/db";
import {
Todo,
TodoAiEnrichDraft,
TodoAiEnrichResponse,
TodoCreateDto,
TodoListQuery,
TodoPatchDto,
TodoPriority,
} from "@ordinarthur-os/shared";
import { and, arrayOverlaps, desc, eq, ilike, ne, sql } from "drizzle-orm";
import { InjectDb, type Db } from "@/db/db.module";
const { todos } = schema;
@Injectable()
export class TodosService {
constructor(@InjectDb() private readonly db: Db) {}
async list(query: TodoListQuery): Promise<Todo[]> {
const conditions = [];
if (query.archived === true) {
conditions.push(eq(todos.status, "archived"));
} else if (!query.status) {
conditions.push(ne(todos.status, "archived"));
}
if (query.status) conditions.push(eq(todos.status, query.status));
if (typeof query.priority === "number")
conditions.push(eq(todos.priority, query.priority));
if (query.context) conditions.push(ilike(todos.context, `%${query.context}%`));
if (query.project_id) conditions.push(eq(todos.projectId, query.project_id));
if (query.tags?.length) conditions.push(arrayOverlaps(todos.tags, query.tags));
const rows = await this.db
.select()
.from(todos)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(todos.createdAt))
.limit(500);
return rows.map(rowToTodo);
}
async create(input: TodoCreateDto): Promise<Todo> {
const rows = await this.db
.insert(todos)
.values(dtoToInsert(input))
.returning();
const row = rows[0];
if (!row) throw new Error("Insert returned no row");
return rowToTodo(row);
}
async patch(id: string, patch: TodoPatchDto): Promise<Todo> {
const existing = await this.getById(id);
const values = dtoToInsert({ ...existing, ...patch } as TodoCreateDto);
// completed_at : si le patch le précise explicitement, on l'honore ;
// sinon on le dérive du status.
let completedAt: Date | null | undefined = undefined;
if ("completed_at" in patch && patch.completed_at !== undefined) {
completedAt = patch.completed_at ? new Date(patch.completed_at) : null;
} else if (patch.status === "done") {
completedAt = new Date();
} else if (patch.status) {
completedAt = null;
}
const [row] = await this.db
.update(todos)
.set({
...values,
...(completedAt !== undefined ? { completedAt } : {}),
...(patch.ai_enriched !== undefined ? { aiEnriched: patch.ai_enriched } : {}),
})
.where(eq(todos.id, id))
.returning();
if (!row) throw new NotFoundException(`Todo ${id} not found`);
return rowToTodo(row);
}
async remove(id: string): Promise<void> {
await this.db.delete(todos).where(eq(todos.id, id));
}
async aiEnrich(id: string): Promise<TodoAiEnrichResponse> {
const todo = await this.getById(id);
const draft = buildEnrichmentDraft(todo);
return { todo_id: todo.id, draft };
}
async applyAiEnrich(id: string, draft: TodoAiEnrichDraft): Promise<Todo> {
const todo = await this.getById(id);
return this.patch(id, {
description: draft.description ?? todo.description,
priority: draft.priority ?? todo.priority,
tags: draft.tags ?? todo.tags,
context: draft.context ?? todo.context,
energy: draft.energy ?? todo.energy,
verification_steps: draft.verification_steps ?? todo.verification_steps,
ai_enriched: true,
});
}
private async getById(id: string): Promise<Todo> {
const [row] = await this.db.select().from(todos).where(eq(todos.id, id));
if (!row) throw new NotFoundException(`Todo ${id} not found`);
return rowToTodo(row);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rowToTodo(row: typeof todos.$inferSelect): Todo {
return {
id: row.id,
title: row.title,
description: row.description ?? null,
status: row.status as Todo["status"],
priority: row.priority as TodoPriority | null,
due_at: row.dueAt?.toISOString() ?? null,
tags: row.tags ?? [],
project_id: row.projectId ?? null,
checklist: (row.checklist as Todo["checklist"]) ?? [],
energy: row.energy as Todo["energy"] ?? null,
context: row.context ?? null,
recurrence: row.recurrence ?? null,
ticket_url: row.ticketUrl ?? null,
verification_steps: row.verificationSteps ?? [],
ai_enriched: row.aiEnriched,
created_at: row.createdAt.toISOString(),
completed_at: row.completedAt?.toISOString() ?? null,
};
}
function dtoToInsert(input: TodoCreateDto) {
return {
title: input.title,
description: input.description ?? null,
status: input.status ?? "inbox",
priority: input.priority ?? null,
dueAt: input.due_at ? new Date(input.due_at) : null,
tags: input.tags ?? [],
projectId: input.project_id ?? null,
checklist: input.checklist ?? [],
energy: input.energy ?? null,
context: input.context ?? null,
recurrence: input.recurrence ?? null,
ticketUrl: input.ticket_url ?? null,
verificationSteps: input.verification_steps ?? [],
};
}
// ---------------------------------------------------------------------------
// Enrichissement IA heuristique (remplacé par Mistral en Phase 5)
// ---------------------------------------------------------------------------
function buildEnrichmentDraft(todo: Todo): TodoAiEnrichDraft {
const title = todo.title.toLowerCase();
const tags = inferTags(todo.title, todo.tags);
if (title.includes("appeler") || title.includes("call")) {
return {
description: todo.description ?? "Préparer les points, lancer l'appel, noter le prochain pas.",
priority: todo.priority ?? 2,
tags,
context: todo.context ?? "@phone",
energy: todo.energy ?? "med",
};
}
if (title.includes("mail") || title.includes("email") || title.includes("répondre")) {
return {
description: todo.description ?? "Rédiger, vérifier les PJ et envoyer.",
priority: todo.priority ?? 1,
tags,
context: todo.context ?? "@laptop",
energy: todo.energy ?? "low",
};
}
return {
description: todo.description ?? "Découper en une première action concrète et avancer.",
priority: todo.priority ?? 1,
tags,
context: todo.context ?? "@focus",
energy: todo.energy ?? "med",
};
}
function inferTags(title: string, currentTags: string[]) {
const base = new Set(currentTags.map((t) => t.trim()).filter(Boolean));
const tokens = title
.toLowerCase()
.split(/[^a-z0-9àâçéèêëîïôûùüÿñæœ]+/i)
.filter((t) => t.length >= 4)
.filter((t) => !STOPWORDS.has(t))
.slice(0, 4);
for (const token of tokens) base.add(token);
return Array.from(base);
}
const STOPWORDS = new Set(["avec", "dans", "pour", "faire", "plus", "arthur", "todo", "that", "this", "from"]);

View File

@ -8,6 +8,10 @@
"emitDecoratorMetadata": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"verbatimModuleSyntax": false,
"isolatedModules": false
},

View File

@ -1,15 +1,28 @@
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/
# ── Stage 1 : build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /workspace
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
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 packages/db/package.json packages/db/
RUN pnpm install --frozen-lockfile
COPY . .
# L'URL de l'API est baked-in au build (Vite env var)
ARG VITE_API_BASE_URL=https://api.os.arthurbarre.fr
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN pnpm --filter @ordinarthur-os/pwa build
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS runtime
COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html
COPY --from=builder /workspace/apps/pwa/dist /usr/share/nginx/html
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -11,6 +11,9 @@
"lint": "eslint \"src/**/*.{ts,tsx}\""
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@ordinarthur-os/shared": "workspace:*",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.58.7",

View File

@ -47,3 +47,27 @@ export async function api<T = unknown>(
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
/**
* Upload binaire (ex : blob audio). Omet le Content-Type JSON par défaut pour
* laisser le navigateur poser celui du blob.
*/
export async function apiBinary<T = unknown>(
path: string,
body: Blob | ArrayBuffer,
contentType: string,
): Promise<T> {
const finalHeaders: Record<string, string> = { "Content-Type": contentType };
const token = getToken();
if (token) finalHeaders.Authorization = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: finalHeaders,
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiError(res.status, text || res.statusText);
}
return res.json() as Promise<T>;
}

View File

@ -0,0 +1,58 @@
import type { CalendarEvent } from "@ordinarthur-os/shared";
interface Props {
event: CalendarEvent;
onDelete?: (id: string) => void;
}
export function EventCard({ event, onDelete }: Props) {
const timeLabel = event.all_day
? "Toute la journée"
: `${formatTime(event.starts_at)} ${formatTime(event.ends_at)}`;
return (
<article className="flex items-start gap-4 px-4 py-3">
{/* Barre couleur + heure */}
<div className="flex-shrink-0 flex flex-col items-end w-[4.5rem]">
<span className="font-mono text-[10px] text-muted leading-tight text-right">
{timeLabel}
</span>
{event.source === "google" && (
<span className="font-mono text-[9px] uppercase tracking-label text-muted mt-0.5">
Google
</span>
)}
</div>
{/* Séparateur vertical */}
<div className="w-px self-stretch bg-ink flex-shrink-0" />
{/* Contenu */}
<div className="flex-1 min-w-0 space-y-0.5">
<p className="font-sans text-sm text-ink leading-snug truncate">{event.title}</p>
{event.location && (
<p className="font-mono text-[10px] text-muted truncate">{event.location}</p>
)}
</div>
{/* Supprimer (événements locaux uniquement) */}
{onDelete && event.source !== "google" && (
<button
type="button"
onClick={() => onDelete(event.id)}
aria-label="Supprimer"
className="flex-shrink-0 font-mono text-[10px] text-muted hover:text-ink transition-colors px-1"
>
×
</button>
)}
</article>
);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}

View File

@ -0,0 +1,122 @@
import { useState } from "react";
import type { CalendarEventCreateDto } from "@ordinarthur-os/shared";
interface Props {
defaultDate: Date;
onSubmit: (dto: CalendarEventCreateDto) => void;
onCancel: () => void;
isPending?: boolean;
}
export function NewEventForm({ defaultDate, onSubmit, onCancel, isPending = false }: Props) {
const dateStr = toLocalDatetimeValue(defaultDate);
const [title, setTitle] = useState("");
const [startsAt, setStartsAt] = useState(dateStr);
const [endsAt, setEndsAt] = useState(addHour(dateStr));
const [location, setLocation] = useState("");
const [allDay, setAllDay] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
onSubmit({
title: title.trim(),
starts_at: new Date(startsAt).toISOString(),
ends_at: new Date(endsAt).toISOString(),
location: location.trim() || null,
all_day: allDay,
});
}
return (
<form onSubmit={handleSubmit} className="border border-ink divide-y divide-ink">
{/* Titre */}
<input
autoFocus
type="text"
placeholder="Titre de l'événement"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
{/* Tout la journée */}
<label className="flex items-center gap-3 px-4 py-3 cursor-pointer">
<input
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="w-4 h-4 border border-ink bg-transparent accent-ink cursor-pointer"
/>
<span className="font-mono text-[11px] uppercase tracking-label text-muted">
Toute la journée
</span>
</label>
{/* Dates */}
{!allDay && (
<div className="grid grid-cols-2 divide-x divide-ink">
<div className="px-4 py-3 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Début</span>
<input
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
/>
</div>
<div className="px-4 py-3 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Fin</span>
<input
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
/>
</div>
</div>
)}
{/* Lieu */}
<input
type="text"
placeholder="Lieu (optionnel)"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted"
/>
{/* Actions */}
<div className="flex items-center justify-end gap-3 px-4 py-3">
<button
type="button"
onClick={onCancel}
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={!title.trim() || isPending}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg disabled:opacity-40"
>
Créer
</button>
</div>
</form>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function toLocalDatetimeValue(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function addHour(s: string): string {
const d = new Date(s);
d.setHours(d.getHours() + 1);
return toLocalDatetimeValue(d);
}

View File

@ -0,0 +1,96 @@
const DAYS_SHORT = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"];
interface Props {
selectedDate: Date;
onSelect: (date: Date) => void;
onPrevWeek: () => void;
onNextWeek: () => void;
}
export function WeekStrip({ selectedDate, onSelect, onPrevWeek, onNextWeek }: Props) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekDays = getWeekDays(selectedDate);
const [first, last] = [weekDays[0]!, weekDays[6]!];
return (
<div className="border border-ink">
{/* Navigation semaine */}
<div className="flex items-center justify-between border-b border-ink px-4 py-2">
<button
type="button"
onClick={onPrevWeek}
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
>
Préc.
</button>
<span className="font-mono text-[11px] uppercase tracking-label text-ink">
{formatWeekLabel(first, last)}
</span>
<button
type="button"
onClick={onNextWeek}
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors"
>
Suiv.
</button>
</div>
{/* Jours */}
<div className="grid grid-cols-7 divide-x divide-ink">
{weekDays.map((day) => {
const isToday = day.getTime() === today.getTime();
const isSelected = day.getTime() === normalize(selectedDate).getTime();
return (
<button
key={day.toISOString()}
type="button"
onClick={() => onSelect(day)}
className={[
"flex flex-col items-center gap-1 py-3 transition-colors",
isSelected
? "bg-ink text-bg"
: "text-ink hover:bg-ink/5",
].join(" ")}
>
<span className="font-mono text-[9px] uppercase tracking-label opacity-70">
{DAYS_SHORT[day.getDay()]}
</span>
<span className={`font-sans text-sm font-light leading-none ${isToday && !isSelected ? "text-accent" : ""}`}>
{day.getDate()}
</span>
</button>
);
})}
</div>
</div>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function normalize(d: Date): Date {
const c = new Date(d);
c.setHours(0, 0, 0, 0);
return c;
}
function getWeekDays(ref: Date): Date[] {
const d = normalize(ref);
// Lundi = premier jour de la semaine
const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
const monday = new Date(d);
monday.setDate(d.getDate() - day);
return Array.from({ length: 7 }, (_, i) => {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
return date;
});
}
function formatWeekLabel(from: Date, to: Date): string {
const f = (d: Date) =>
d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" });
return `${f(from)} ${f(to)}`;
}

View File

@ -0,0 +1,167 @@
import type { ProposedAction } from "@ordinarthur-os/shared";
interface Props {
action: ProposedAction;
onChange: (updated: ProposedAction) => void;
}
/**
* Formulaire d'édition inline adapté au type d'action.
* Chaque type d'action a son propre UI.
*/
export function ActionEditor({ action, onChange }: Props) {
switch (action.fn) {
case "create_calendar_event":
return <CalendarEventEditor action={action} onChange={onChange} />;
case "create_todo":
return <TodoEditor action={action} onChange={onChange} />;
default:
return null;
}
}
// ── Éditeur d'événement calendrier ───────────────────────────────────────────
function CalendarEventEditor({
action,
onChange,
}: {
action: Extract<ProposedAction, { fn: "create_calendar_event" }>;
onChange: (a: ProposedAction) => void;
}) {
const args = action.args;
function patch(partial: Partial<typeof args>) {
onChange({ ...action, args: { ...args, ...partial } });
}
return (
<div className="border-t border-ink divide-y divide-ink">
{/* Titre */}
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Titre</span>
<input
type="text"
value={args.title}
onChange={(e) => patch({ title: e.target.value })}
className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent"
/>
</div>
{/* Début / Fin */}
<div className="grid grid-cols-2 divide-x divide-ink">
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Début</span>
<input
type="datetime-local"
value={toLocalInput(args.starts_at)}
onChange={(e) => patch({ starts_at: fromLocalInput(e.target.value) })}
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
/>
</div>
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Fin</span>
<input
type="datetime-local"
value={toLocalInput(args.ends_at)}
onChange={(e) => patch({ ends_at: fromLocalInput(e.target.value) })}
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
/>
</div>
</div>
{/* Lieu */}
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Lieu (optionnel)</span>
<input
type="text"
value={args.location ?? ""}
onChange={(e) => patch({ location: e.target.value || undefined })}
className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent"
/>
</div>
</div>
);
}
// ── Éditeur de tâche ─────────────────────────────────────────────────────────
function TodoEditor({
action,
onChange,
}: {
action: Extract<ProposedAction, { fn: "create_todo" }>;
onChange: (a: ProposedAction) => void;
}) {
const args = action.args;
function patch(partial: Partial<typeof args>) {
onChange({ ...action, args: { ...args, ...partial } });
}
return (
<div className="border-t border-ink divide-y divide-ink">
{/* Titre */}
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Titre</span>
<input
type="text"
value={args.title}
onChange={(e) => patch({ title: e.target.value })}
className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent"
/>
</div>
{/* Échéance */}
<div className="px-3 py-2 space-y-1">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Échéance (optionnel)</span>
<input
type="datetime-local"
value={args.due_at ? toLocalInput(args.due_at) : ""}
onChange={(e) =>
patch({ due_at: e.target.value ? fromLocalInput(e.target.value) : undefined })
}
className="w-full bg-transparent font-sans text-sm text-ink outline-none"
/>
</div>
{/* Priorité */}
<div className="px-3 py-2 flex items-center gap-4">
<span className="font-mono text-[9px] uppercase tracking-label text-muted">Priorité</span>
{([0, 1, 2, 3] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => patch({ priority: p })}
className={[
"font-mono text-[10px] uppercase tracking-label px-2 py-1 border transition-colors",
args.priority === p
? "border-ink bg-ink text-bg"
: "border-ink text-muted hover:text-ink",
].join(" ")}
>
{p === 0 ? "Bas" : p === 1 ? "Normal" : p === 2 ? "Haut" : "Urgent"}
</button>
))}
</div>
</div>
);
}
// ── Helpers datetime-local ────────────────────────────────────────────────────
/** ISO 8601 (avec ou sans offset) → valeur pour input[type=datetime-local] */
function toLocalInput(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Valeur datetime-local → ISO 8601 avec offset Europe/Paris */
function fromLocalInput(value: string): string {
if (!value) return "";
const d = new Date(value); // interprété en heure locale du navigateur
return d.toISOString(); // UTC en base, correct pour le stockage
}

View File

@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from "react";
import type { AiVoiceResponse } from "@ordinarthur-os/shared";
import { apiBinary } from "@/api/client";
import { cn } from "@/lib/cn";
import { VoiceConfirmModal } from "./VoiceConfirmModal";
type Phase = "idle" | "recording" | "uploading" | "reviewing" | "error";
/**
* Le magic button est l'entrée principale d'ordinarthur-os :
* 1. Clic (ou hold) MediaRecorder capture l'audio
* 2. Relâche envoie le blob à POST /ai/voice
* 3. Modale affiche transcript + actions proposées confirme côté UI
*/
export function MagicButton() {
const [phase, setPhase] = useState<Phase>("idle");
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<AiVoiceResponse | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
useEffect(() => {
return () => stopStream();
}, []);
function stopStream() {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
async function startRecording() {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = pickMimeType();
const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
void uploadRecording(recorder.mimeType);
};
recorder.start();
mediaRecorderRef.current = recorder;
setPhase("recording");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(`Accès au micro refusé : ${msg}`);
setPhase("error");
stopStream();
}
}
async function stopRecording() {
const recorder = mediaRecorderRef.current;
if (!recorder || recorder.state === "inactive") return;
recorder.stop();
stopStream();
setPhase("uploading");
}
async function uploadRecording(mime: string) {
try {
const blob = new Blob(chunksRef.current, { type: mime });
if (blob.size === 0) {
setPhase("idle");
return;
}
const contentType = mime || "audio/webm";
const response = await apiBinary<AiVoiceResponse>("/ai/voice", blob, contentType);
setResult(response);
setPhase("reviewing");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(`Upload échoué : ${msg}`);
setPhase("error");
}
}
function handleClick() {
if (phase === "idle" || phase === "error") {
void startRecording();
} else if (phase === "recording") {
void stopRecording();
}
}
function handleModalClose() {
setResult(null);
setPhase("idle");
}
const label = {
idle: "Enregistrer",
recording: "Stop",
uploading: "Transcription…",
reviewing: "…",
error: "Réessayer",
}[phase];
const isActive = phase === "recording";
const isBusy = phase === "uploading";
const hint =
phase === "recording"
? "parle · clique pour stop"
: phase === "uploading"
? "whisper + mistral…"
: phase === "error"
? "réessaie"
: "clique pour parler";
return (
<>
<div className="w-full max-w-xl border-2 border-ink">
<div className="flex items-center justify-between border-b border-ink px-4 py-2">
<span className="font-mono text-[10px] uppercase tracking-label text-muted">
[ VOICE ]
</span>
<span
className={cn(
"flex items-center gap-2 font-mono text-[10px] uppercase tracking-label",
isActive ? "text-accent" : "text-muted",
)}
>
<span
className={cn(
"w-1.5 h-1.5",
isActive ? "bg-accent animate-pulse" : "bg-muted",
)}
aria-hidden
/>
{isActive ? "REC" : isBusy ? "…" : "IDLE"}
</span>
</div>
<button
type="button"
onClick={handleClick}
disabled={isBusy}
aria-label="Magic button voice"
aria-pressed={isActive}
className={cn(
"relative block w-full px-6 py-10 text-left",
"font-sans font-light tracking-tightest",
"text-[clamp(2rem,6vw,3.5rem)] leading-none",
"transition-colors duration-150",
"disabled:opacity-60 disabled:cursor-not-allowed",
isActive
? "bg-accent text-bg"
: "bg-bg text-ink hover:bg-ink hover:text-bg",
)}
>
{label}
{isActive && (
<span
className="absolute bottom-3 right-4 w-2 h-2 bg-bg animate-pulse"
aria-hidden
/>
)}
</button>
<div className="flex items-center justify-between border-t border-ink px-4 py-2">
<span className="font-mono text-[10px] uppercase tracking-label text-muted">
{hint}
</span>
{error && (
<span className="font-mono text-[10px] uppercase tracking-label text-accent truncate max-w-[60%]">
{error}
</span>
)}
</div>
</div>
{phase === "reviewing" && result && (
<VoiceConfirmModal response={result} onClose={handleModalClose} />
)}
</>
);
}
/**
* MediaRecorder formats supportés diffèrent Safari/Chrome/Firefox.
* On essaye WebM/Opus d'abord, puis fallbacks.
*/
function pickMimeType(): string | null {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
for (const t of candidates) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) {
return t;
}
}
return null;
}

View File

@ -0,0 +1,117 @@
import { useEffect, useRef, useState } from "react";
import type { AiVoiceResponse } from "@ordinarthur-os/shared";
import { apiBinary } from "@/api/client";
import { cn } from "@/lib/cn";
import { VoiceConfirmModal } from "./VoiceConfirmModal";
type Phase = "idle" | "recording" | "uploading" | "reviewing" | "error";
interface Props {
/** "sm" = bouton compact (icône + label court), "default" = carré large */
size?: "sm" | "default";
}
/**
* Version compacte du magic button, intégrable dans n'importe quelle page.
*/
export function VoiceButton({ size = "default" }: Props) {
const [phase, setPhase] = useState<Phase>("idle");
const [result, setResult] = useState<AiVoiceResponse | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
useEffect(() => () => streamRef.current?.getTracks().forEach((t) => t.stop()), []);
async function toggle() {
if (phase === "recording") {
recorderRef.current?.stop();
streamRef.current?.getTracks().forEach((t) => t.stop());
setPhase("uploading");
return;
}
if (phase === "uploading" || phase === "reviewing") return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = pickMime();
const rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
chunksRef.current = [];
rec.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
rec.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: rec.mimeType });
if (!blob.size) { setPhase("idle"); return; }
try {
const resp = await apiBinary<AiVoiceResponse>("/ai/voice", blob, rec.mimeType || "audio/webm");
setResult(resp);
setPhase("reviewing");
} catch {
setPhase("error");
}
};
rec.start();
recorderRef.current = rec;
setPhase("recording");
} catch {
setPhase("error");
}
}
const isRecording = phase === "recording";
const isBusy = phase === "uploading";
if (size === "sm") {
return (
<>
<button
type="button"
onClick={() => void toggle()}
disabled={isBusy}
title={isRecording ? "Stop" : "Parler (voice)"}
className={cn(
"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-label border px-2 py-1 transition-colors",
isRecording
? "border-accent bg-accent text-bg"
: "border-ink text-muted hover:text-ink disabled:opacity-40",
)}
>
<span className={cn("w-1.5 h-1.5 flex-shrink-0", isRecording ? "bg-bg animate-pulse" : "bg-current")} />
{isBusy ? "…" : isRecording ? "Stop" : "Voix"}
</button>
{phase === "reviewing" && result && (
<VoiceConfirmModal response={result} onClose={() => { setResult(null); setPhase("idle"); }} />
)}
</>
);
}
// Default (same as MagicButton but simplified)
return (
<>
<button
type="button"
onClick={() => void toggle()}
disabled={isBusy}
className={cn(
"border-2 border-ink px-6 py-4 font-sans text-base font-light w-full transition-colors",
isRecording ? "bg-accent text-bg" : "bg-bg text-ink hover:bg-ink hover:text-bg",
isBusy && "opacity-50 cursor-not-allowed",
)}
>
{isBusy ? "Traitement…" : isRecording ? "Stop" : "🎤 Parler"}
</button>
{phase === "reviewing" && result && (
<VoiceConfirmModal response={result} onClose={() => { setResult(null); setPhase("idle"); }} />
)}
</>
);
}
function pickMime(): string | null {
for (const t of ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]) {
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) return t;
}
return null;
}

View File

@ -0,0 +1,303 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
AiConfirmRequest,
AiConfirmResponse,
AiVoiceResponse,
ProposedAction,
ProposedActionWithId,
} from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { Label } from "@/design";
import { cn } from "@/lib/cn";
import { ActionEditor } from "./ActionEditor";
interface Props {
response: AiVoiceResponse;
onClose: () => void;
}
export function VoiceConfirmModal({ response, onClose }: Props) {
const queryClient = useQueryClient();
// Actions sélectionnées (toutes par défaut)
const initialIds = useMemo(
() => new Set(response.actions.map((a) => a.id)),
[response.actions],
);
const [selected, setSelected] = useState<Set<string>>(initialIds);
// Args édités localement avant confirmation
const [editedArgs, setEditedArgs] = useState<Map<string, ProposedAction>>(new Map());
// Quelle action est en mode édition
const [editing, setEditing] = useState<string | null>(null);
const confirm = useMutation({
mutationFn: (body: AiConfirmRequest) =>
api<AiConfirmResponse>("/ai/command/confirm", {
method: "POST",
body: JSON.stringify(body),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["todos"] });
void queryClient.invalidateQueries({ queryKey: ["agenda"] });
void queryClient.invalidateQueries({ queryKey: ["health-tab"] });
void queryClient.invalidateQueries({ queryKey: ["ideas", "free"] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
},
});
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && !confirm.isPending) onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, confirm.isPending]);
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleEdit(id: string, updated: ProposedAction) {
setEditedArgs((prev) => new Map(prev).set(id, updated));
}
async function handleConfirm() {
const ids = Array.from(selected);
if (ids.length === 0) { onClose(); return; }
// Patch les args modifiés avant de confirmer
for (const id of ids) {
const edited = editedArgs.get(id);
if (edited) {
await api(`/ai/actions/${id}/args`, {
method: "PATCH",
body: JSON.stringify({ args: edited.args }),
});
}
}
confirm.mutate({ action_ids: ids });
}
const hasActions = response.actions.length > 0;
return (
<div
className="fixed inset-0 z-50 bg-ink/40 flex items-end sm:items-center justify-center p-0 sm:p-4"
onClick={() => !confirm.isPending && onClose()}
>
<div
className="w-full sm:max-w-xl bg-bg border-t-2 sm:border-2 border-ink max-h-[90dvh] flex flex-col"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{/* En-tête */}
<header className="border-b border-ink px-4 py-3 flex items-center justify-between flex-shrink-0">
<Label prefix="[ VOICE ]">CONFIRMATION</Label>
<button
type="button"
onClick={onClose}
disabled={confirm.isPending}
aria-label="Fermer"
className="font-mono text-sm text-muted hover:text-ink disabled:opacity-30"
>
×
</button>
</header>
{/* Transcript */}
<section className="px-4 py-3 border-b border-ink flex-shrink-0">
<Label className="mb-1">TRANSCRIPT</Label>
<p className="font-sans text-sm leading-6 text-ink mt-1">
{response.transcript || <span className="text-muted">Aucun audio reconnu.</span>}
</p>
</section>
{/* Actions — scrollable */}
<section className="overflow-y-auto flex-1 px-4 py-3 space-y-2">
<Label>{hasActions ? "ACTIONS PROPOSÉES" : "AUCUNE ACTION"}</Label>
{hasActions ? (
<ul className="space-y-2 mt-2">
{response.actions.map((a) => {
const effective = editedArgs.get(a.id) ?? a.action;
const isEditing = editing === a.id;
return (
<ActionRow
key={a.id}
action={{ ...a, action: effective }}
selected={selected.has(a.id)}
isEditing={isEditing}
onToggle={() => toggle(a.id)}
onToggleEdit={() => setEditing(isEditing ? null : a.id)}
onEdit={(updated) => handleEdit(a.id, updated)}
isEdited={editedArgs.has(a.id)}
/>
);
})}
</ul>
) : (
<p className="font-sans text-sm text-muted mt-2">
Mistral n'a pas trouvé d'action à proposer. Reformule ou ferme.
</p>
)}
</section>
{/* Footer */}
<footer className="border-t border-ink px-4 py-3 flex items-center justify-between gap-3 flex-shrink-0">
{confirm.isError && (
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
Échec réessaie
</span>
)}
<div className="flex items-center gap-2 ml-auto">
<button
type="button"
onClick={onClose}
disabled={confirm.isPending}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink hover:bg-ink hover:text-bg disabled:opacity-40"
>
Annuler
</button>
<button
type="button"
onClick={() => void handleConfirm()}
disabled={!hasActions || selected.size === 0 || confirm.isPending}
className="border border-ink bg-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-bg hover:bg-accent hover:border-accent disabled:opacity-40"
>
{confirm.isPending ? "Exécution…" : `Confirmer (${selected.size})`}
</button>
</div>
</footer>
</div>
</div>
);
}
// ── ActionRow ─────────────────────────────────────────────────────────────────
function ActionRow({
action,
selected,
isEditing,
isEdited,
onToggle,
onToggleEdit,
onEdit,
}: {
action: ProposedActionWithId;
selected: boolean;
isEditing: boolean;
isEdited: boolean;
onToggle: () => void;
onToggleEdit: () => void;
onEdit: (updated: ProposedAction) => void;
}) {
const canEdit = action.action.fn === "create_calendar_event" || action.action.fn === "create_todo";
return (
<li className="border border-ink">
<div className="flex items-start gap-3 px-3 py-2">
{/* Checkbox */}
<button
type="button"
onClick={onToggle}
aria-pressed={selected}
className="mt-0.5 flex-shrink-0 w-4 h-4 border border-ink"
>
<span className={cn("block w-full h-full", selected ? "bg-ink" : "bg-transparent")} />
</button>
{/* Résumé */}
<div className="flex-1 min-w-0">
<div className="font-mono text-[10px] uppercase tracking-label text-muted">
{functionLabel(action.action.fn)}
{isEdited && (
<span className="ml-2 text-accent">· modifié</span>
)}
</div>
<div className="font-sans text-sm text-ink mt-0.5">{summarize(action.action)}</div>
</div>
{/* Bouton éditer */}
{canEdit && (
<button
type="button"
onClick={onToggleEdit}
className={cn(
"flex-shrink-0 font-mono text-[10px] uppercase tracking-label px-2 py-1 border transition-colors",
isEditing
? "border-ink bg-ink text-bg"
: "border-ink text-muted hover:text-ink",
)}
>
{isEditing ? "Fermer" : "Éditer"}
</button>
)}
</div>
{/* Formulaire d'édition inline */}
{isEditing && (
<ActionEditor action={action.action} onChange={onEdit} />
)}
</li>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function functionLabel(fn: ProposedAction["fn"]): string {
switch (fn) {
case "create_todo": return "Créer une tâche";
case "capture_idea": return "Capturer une idée";
case "add_project_idea": return "Idée de projet";
case "add_project_step": return "Étape de projet";
case "create_calendar_event": return "Événement agenda";
case "toggle_daily_checkin": return "Check-in du jour";
}
}
function summarize(action: ProposedAction): string {
switch (action.fn) {
case "create_todo": {
const parts = [action.args.title];
if (action.args.due_at) parts.push(`· ${formatDatetime(action.args.due_at)}`);
if (typeof action.args.priority === "number") parts.push(`· p${action.args.priority}`);
return parts.join(" ");
}
case "add_project_idea":
return action.args.content;
case "add_project_step":
return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`;
case "create_calendar_event":
return `${action.args.title} · ${formatDatetime(action.args.starts_at)}${formatTime(action.args.ends_at)}`;
case "toggle_daily_checkin":
return action.args.note ?? "Check-in du jour";
case "capture_idea":
return action.args.content;
}
}
function formatDatetime(iso: string): string {
try {
return new Date(iso).toLocaleString("fr-FR", {
day: "2-digit", month: "short",
hour: "2-digit", minute: "2-digit",
});
} catch { return iso; }
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
} catch { return iso; }
}

View File

@ -0,0 +1,39 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { DailyCheckin } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { Label } from "@/design";
import { MedsSlider } from "./MedsSlider";
export function MedsSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["health-tab", "today"],
queryFn: () => api<DailyCheckin>("/health-tab/today"),
});
const toggle = useMutation({
mutationFn: () =>
api<DailyCheckin>("/health-tab/today/toggle", { method: "POST", body: "{}" }),
onSuccess: (updated) => qc.setQueryData(["health-tab", "today"], updated),
});
const medsTaken = data?.meds_taken ?? false;
return (
<section className="border border-ink">
<div className="flex items-center justify-between px-4 py-3 border-b border-ink">
<Label prefix="[ 02 ]">MÉDOCS DU JOUR</Label>
<span className={`font-mono text-[11px] uppercase tracking-label ${medsTaken ? "text-accent" : "text-muted"}`}>
{isLoading ? "…" : medsTaken ? "Pris" : "Non pris"}
</span>
</div>
<MedsSlider
medsTaken={medsTaken}
onToggle={() => toggle.mutate()}
disabled={toggle.isPending || isLoading}
/>
</section>
);
}

View File

@ -0,0 +1,108 @@
import { useRef, useState, useEffect } from "react";
const THUMB_W = 64;
interface Props {
medsTaken: boolean;
onToggle: () => void;
disabled?: boolean;
}
export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) {
const trackRef = useRef<HTMLDivElement>(null);
const [dragX, setDragX] = useState(0);
const dragging = useRef(false);
const startX = useRef(0);
useEffect(() => {
if (!medsTaken) setDragX(0);
}, [medsTaken]);
const maxX = () => (trackRef.current?.clientWidth ?? 300) - THUMB_W;
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || medsTaken) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
startX.current = e.clientX - dragX;
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDragX(Math.max(0, Math.min(e.clientX - startX.current, maxX())));
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
if (dragX >= maxX() * 0.65) {
onToggle();
} else {
setDragX(0);
}
};
const progress = medsTaken ? 1 : dragX / (maxX() || 1);
const isActive = dragging.current;
return (
<div
ref={trackRef}
onClick={() => medsTaken && !disabled && onToggle()}
className="relative h-16 border border-ink overflow-hidden select-none"
style={{ cursor: medsTaken ? "pointer" : "default" }}
>
{/* Fill orange qui suit le thumb */}
<div
className="absolute inset-y-0 left-0 bg-accent"
style={{
width: `${progress * 100}%`,
transition: isActive ? "none" : "width 0.2s ease",
}}
/>
{/* Labels */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{!medsTaken && (
<span
className="font-mono text-[11px] uppercase tracking-label text-ink"
style={{ opacity: Math.max(0, 1 - progress * 2.5) }}
>
Glisse médicaments pris
</span>
)}
{!medsTaken && progress > 0.3 && (
<span
className="absolute font-mono text-[11px] uppercase tracking-label text-bg"
style={{ opacity: Math.min(1, (progress - 0.3) * 3) }}
>
Lâche pour confirmer
</span>
)}
{medsTaken && (
<span className="font-mono text-[11px] uppercase tracking-label text-bg">
Médicaments pris tap pour annuler
</span>
)}
</div>
{/* Thumb */}
<div
className="absolute top-0 bottom-0 z-10 flex items-center justify-center bg-ink"
style={{
width: THUMB_W,
left: medsTaken ? `calc(100% - ${THUMB_W}px)` : dragX,
transition: isActive ? "none" : "left 0.2s ease",
cursor: disabled ? "not-allowed" : medsTaken ? "pointer" : "grab",
opacity: disabled && !medsTaken ? 0.4 : 1,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<span className="font-mono text-bg text-lg">{medsTaken ? "✓" : "→"}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectIdea } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { cn } from "@/lib/cn";
const PRIORITY_LABELS = ["Bas", "Normal", "Haut", "Urgent"] as const;
interface Props {
projectId: string;
ideas: ProjectIdea[];
}
export function IdeaList({ projectId, ideas }: Props) {
const queryClient = useQueryClient();
const [content, setContent] = useState("");
const [priority, setPriority] = useState<number | null>(null);
const addIdea = useMutation({
mutationFn: () =>
api(`/projects/${projectId}/ideas`, {
method: "POST",
body: JSON.stringify({ content, priority }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "ideas"] });
setContent("");
setPriority(null);
},
});
function submit() {
if (!content.trim()) return;
addIdea.mutate();
}
const sorted = [...ideas].sort((a, b) => {
const pa = a.priority ?? -1;
const pb = b.priority ?? -1;
return pb - pa || a.created_at.localeCompare(b.created_at);
});
return (
<div className="space-y-3">
{/* Form */}
<div className="border border-ink p-3 space-y-2">
<span className="font-mono text-[9px] uppercase tracking-label text-muted block">Nouvelle idée</span>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit();
}}
placeholder="Une idée, une piste, une note…"
rows={2}
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
/>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-[9px] uppercase tracking-label text-muted">Priorité :</span>
{([null, 0, 1, 2, 3] as const).map((p) => (
<button
key={String(p)}
type="button"
onClick={() => setPriority(p)}
className={cn(
"font-mono text-[9px] uppercase tracking-label px-2 py-0.5 border transition-colors",
priority === p
? "border-ink bg-ink text-bg"
: "border-ink text-muted hover:text-ink",
)}
>
{p === null ? "—" : PRIORITY_LABELS[p]}
</button>
))}
<button
type="button"
onClick={submit}
disabled={!content.trim() || addIdea.isPending}
className="ml-auto font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg px-3 py-1 disabled:opacity-40 hover:bg-accent hover:border-accent"
>
{addIdea.isPending ? "…" : "Ajouter"}
</button>
</div>
</div>
{/* List */}
{sorted.length === 0 ? (
<p className="font-sans text-sm text-muted">Aucune idée pour l'instant.</p>
) : (
<ul className="space-y-2">
{sorted.map((idea) => (
<IdeaRow key={idea.id} idea={idea} projectId={projectId} />
))}
</ul>
)}
</div>
);
}
function IdeaRow({ idea, projectId }: { idea: ProjectIdea; projectId: string }) {
const queryClient = useQueryClient();
const deleteIdea = useMutation({
mutationFn: () =>
api(`/projects/${projectId}/ideas/${idea.id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "ideas"] });
},
});
return (
<li className="border border-ink px-3 py-2 flex items-start gap-3 group">
{idea.priority !== null && (
<span className={cn(
"flex-shrink-0 font-mono text-[9px] uppercase tracking-label mt-0.5 px-1",
idea.priority === 3 ? "bg-accent text-bg" :
idea.priority === 2 ? "border border-ink text-ink" :
"text-muted",
)}>
p{idea.priority}
</span>
)}
<span className="flex-1 font-sans text-sm text-ink leading-snug">{idea.content}</span>
<button
type="button"
onClick={() => deleteIdea.mutate()}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
aria-label="Supprimer"
>
×
</button>
</li>
);
}

View File

@ -0,0 +1,338 @@
import { useState } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectStep, StepStatus } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { cn } from "@/lib/cn";
const COLUMNS: { id: StepStatus; label: string }[] = [
{ id: "backlog", label: "Backlog" },
{ id: "todo", label: "À faire" },
{ id: "doing", label: "En cours" },
{ id: "review", label: "Révision" },
{ id: "done", label: "Fait" },
];
interface Props {
projectId: string;
steps: ProjectStep[];
}
export function KanbanBoard({ projectId, steps }: Props) {
const queryClient = useQueryClient();
const [activeStep, setActiveStep] = useState<ProjectStep | null>(null);
// Track over-column during drag
const [overColumn, setOverColumn] = useState<StepStatus | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const patchStep = useMutation({
mutationFn: ({ stepId, status, position }: { stepId: string; status: StepStatus; position: number }) =>
api(`/projects/${projectId}/steps/${stepId}`, {
method: "PATCH",
body: JSON.stringify({ status, position }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
},
});
function handleDragStart(event: DragStartEvent) {
const step = steps.find((s) => s.id === event.active.id);
if (step) setActiveStep(step);
}
function handleDragOver(event: DragOverEvent) {
const overId = event.over?.id as string | undefined;
if (!overId) { setOverColumn(null); return; }
// Over a column header
const col = COLUMNS.find((c) => c.id === overId);
if (col) { setOverColumn(col.id); return; }
// Over a step — get its column
const overStep = steps.find((s) => s.id === overId);
if (overStep) setOverColumn(overStep.status);
}
function handleDragEnd(event: DragEndEvent) {
setActiveStep(null);
setOverColumn(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const draggedStep = steps.find((s) => s.id === active.id);
if (!draggedStep) return;
const overId = over.id as string;
// Determine target column
let targetStatus: StepStatus;
const overCol = COLUMNS.find((c) => c.id === overId);
if (overCol) {
targetStatus = overCol.id;
} else {
const overStep = steps.find((s) => s.id === overId);
if (!overStep) return;
targetStatus = overStep.status;
}
// Get ordered steps in target column (excluding dragged)
const colSteps = steps
.filter((s) => s.status === targetStatus && s.id !== draggedStep.id)
.sort((a, b) => a.position - b.position);
// Find where the dragged step lands within the target column
const overStep = steps.find((s) => s.id === overId && s.id !== draggedStep.id);
let newPosition: number;
if (!overStep || overCol) {
// Dropped on column header or empty column → end of column
const last = colSteps[colSteps.length - 1];
newPosition = last ? last.position + 1000 : 0;
} else {
const overIdx = colSteps.findIndex((s) => s.id === overId);
const prev = colSteps[overIdx - 1];
const next = colSteps[overIdx];
if (prev && next) {
newPosition = (prev.position + next.position) / 2;
} else if (!prev && next) {
newPosition = next.position - 500;
} else if (prev && !next) {
newPosition = prev.position + 1000;
} else {
newPosition = 0;
}
}
patchStep.mutate({ stepId: draggedStep.id, status: targetStatus, position: newPosition });
}
const stepsByCol = (status: StepStatus) =>
steps.filter((s) => s.status === status).sort((a, b) => a.position - b.position);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 md:mx-0 md:px-0">
{COLUMNS.map((col) => {
const colSteps = stepsByCol(col.id);
const isOver = overColumn === col.id && activeStep?.status !== col.id;
return (
<KanbanColumn
key={col.id}
id={col.id}
label={col.label}
steps={colSteps}
projectId={projectId}
isOver={isOver}
/>
);
})}
</div>
<DragOverlay>
{activeStep && <StepCardOverlay step={activeStep} />}
</DragOverlay>
</DndContext>
);
}
// ── Column ────────────────────────────────────────────────────────────────────
function KanbanColumn({
id,
label,
steps,
projectId,
isOver,
}: {
id: StepStatus;
label: string;
steps: ProjectStep[];
projectId: string;
isOver: boolean;
}) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [title, setTitle] = useState("");
const { setNodeRef } = useSortable({ id, data: { type: "column" } });
const createStep = useMutation({
mutationFn: (t: string) =>
api(`/projects/${projectId}/steps`, {
method: "POST",
body: JSON.stringify({ title: t, status: id }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
setTitle("");
setAdding(false);
},
});
function submit() {
const t = title.trim();
if (!t) return;
createStep.mutate(t);
}
return (
<div
ref={setNodeRef}
className={cn(
"flex-shrink-0 w-52 md:w-56 flex flex-col border border-ink transition-colors",
isOver && "border-accent bg-accent/5",
)}
>
{/* Header */}
<div className="px-3 py-2 border-b border-ink flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-label">{label}</span>
<span className="font-mono text-[10px] text-muted">{steps.length}</span>
</div>
{/* Cards */}
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="flex-1 p-2 space-y-2 min-h-[120px]">
{steps.map((step) => (
<StepCard key={step.id} step={step} projectId={projectId} />
))}
</div>
</SortableContext>
{/* Add step */}
<div className="border-t border-ink p-2">
{adding ? (
<div className="space-y-1">
<input
autoFocus
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
if (e.key === "Escape") { setAdding(false); setTitle(""); }
}}
placeholder="Titre de l'étape…"
className="w-full bg-transparent font-sans text-xs text-ink outline-none border-b border-ink focus:border-accent py-1"
/>
<div className="flex gap-1">
<button
type="button"
onClick={submit}
disabled={!title.trim() || createStep.isPending}
className="flex-1 font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg py-1 disabled:opacity-40"
>
{createStep.isPending ? "…" : "Ajouter"}
</button>
<button
type="button"
onClick={() => { setAdding(false); setTitle(""); }}
className="font-mono text-[9px] uppercase tracking-label border border-ink px-2 py-1 text-muted hover:text-ink"
>
×
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="w-full font-mono text-[9px] uppercase tracking-label text-muted hover:text-ink text-left"
>
+ Étape
</button>
)}
</div>
</div>
);
}
// ── Step card (sortable) ──────────────────────────────────────────────────────
function StepCard({ step, projectId }: { step: ProjectStep; projectId: string }) {
const queryClient = useQueryClient();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id, data: { type: "step", step } });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const deleteStep = useMutation({
mutationFn: () =>
api(`/projects/${projectId}/steps/${step.id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId, "steps"] });
},
});
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"border border-ink bg-bg px-2 py-2 group select-none",
isDragging && "opacity-30",
)}
>
<div className="flex items-start gap-1">
<span
{...attributes}
{...listeners}
className="mt-0.5 flex-shrink-0 cursor-grab active:cursor-grabbing text-muted hover:text-ink font-mono text-[10px] leading-none"
aria-label="Déplacer"
>
</span>
<span className="flex-1 font-sans text-xs text-ink leading-snug">{step.title}</span>
<button
type="button"
onClick={() => deleteStep.mutate()}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
aria-label="Supprimer"
>
×
</button>
</div>
</div>
);
}
// ── Overlay (ghost while dragging) ───────────────────────────────────────────
function StepCardOverlay({ step }: { step: ProjectStep }) {
return (
<div className="border border-accent bg-bg px-2 py-2 w-52 md:w-56 shadow-lg rotate-1">
<span className="font-sans text-xs text-ink leading-snug">{step.title}</span>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { Link } from "@tanstack/react-router";
const NAV_ITEMS = [
{ to: "/", label: "Home", symbol: "◎" },
{ to: "/agenda", label: "Agenda", symbol: "◫" },
{ to: "/todos", label: "Todos", symbol: "□" },
{ to: "/projects", label: "Projets", symbol: "◈" },
{ to: "/jobs", label: "Jobs", symbol: "⚡" },
] as const;
export function BottomNav() {
return (
<nav
className="fixed bottom-0 inset-x-0 z-40 flex border-t border-ink bg-bg md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}
>
{NAV_ITEMS.map(({ to, label, symbol }) => (
<Link
key={to}
to={to}
className="flex flex-1 flex-col items-center gap-0.5 py-3 font-mono text-muted transition-colors hover:text-ink [&.active]:text-ink"
activeOptions={{ exact: to === "/" }}
activeProps={{ className: "active" }}
>
<span className="text-base leading-none">{symbol}</span>
<span className="text-[9px] uppercase tracking-label">{label}</span>
</Link>
))}
</nav>
);
}

View File

@ -13,7 +13,7 @@ export function BigHeading({ as: Tag = "h1", className, children, ...props }: Bi
<Tag
className={cn(
"font-sans font-light tracking-tightest text-ink",
"text-[clamp(2.5rem,8vw,6rem)] leading-[0.95]",
"text-[clamp(1.8rem,8vw,6rem)] leading-[0.95]",
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
className,
)}

View File

@ -9,58 +9,143 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as TodosRouteImport } from './routes/todos'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as JobsRouteImport } from './routes/jobs'
import { Route as AgendaRouteImport } from './routes/agenda'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ProjectsIndexRouteImport } from './routes/projects.index'
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
import { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId'
const TodosRoute = TodosRouteImport.update({
id: '/todos',
path: '/todos',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectsRoute = ProjectsRouteImport.update({
id: '/projects',
path: '/projects',
getParentRoute: () => rootRouteImport,
} as any)
const JobsRoute = JobsRouteImport.update({
id: '/jobs',
path: '/jobs',
getParentRoute: () => rootRouteImport,
} as any)
const AgendaRoute = AgendaRouteImport.update({
id: '/agenda',
path: '/agenda',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectsIndexRoute = ProjectsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectsRoute,
} as any)
const SettingsJobsRoute = SettingsJobsRouteImport.update({
id: '/settings/jobs',
path: '/settings/jobs',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({
id: '/$projectId',
path: '/$projectId',
getParentRoute: () => ProjectsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/agenda': typeof AgendaRoute
'/jobs': typeof JobsRoute
'/projects': typeof ProjectsRouteWithChildren
'/todos': typeof TodosRoute
'/projects/$projectId': typeof ProjectsProjectIdRoute
'/settings/jobs': typeof SettingsJobsRoute
'/projects/': typeof ProjectsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/agenda': typeof AgendaRoute
'/jobs': typeof JobsRoute
'/todos': typeof TodosRoute
'/projects/$projectId': typeof ProjectsProjectIdRoute
'/settings/jobs': typeof SettingsJobsRoute
'/projects': typeof ProjectsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/agenda': typeof AgendaRoute
'/jobs': typeof JobsRoute
'/projects': typeof ProjectsRouteWithChildren
'/todos': typeof TodosRoute
'/projects/$projectId': typeof ProjectsProjectIdRoute
'/settings/jobs': typeof SettingsJobsRoute
'/projects/': typeof ProjectsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/jobs' | '/settings/jobs'
fullPaths:
| '/'
| '/agenda'
| '/jobs'
| '/projects'
| '/todos'
| '/projects/$projectId'
| '/settings/jobs'
| '/projects/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/jobs' | '/settings/jobs'
id: '__root__' | '/' | '/jobs' | '/settings/jobs'
to:
| '/'
| '/agenda'
| '/jobs'
| '/todos'
| '/projects/$projectId'
| '/settings/jobs'
| '/projects'
id:
| '__root__'
| '/'
| '/agenda'
| '/jobs'
| '/projects'
| '/todos'
| '/projects/$projectId'
| '/settings/jobs'
| '/projects/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AgendaRoute: typeof AgendaRoute
JobsRoute: typeof JobsRoute
ProjectsRoute: typeof ProjectsRouteWithChildren
TodosRoute: typeof TodosRoute
SettingsJobsRoute: typeof SettingsJobsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/todos': {
id: '/todos'
path: '/todos'
fullPath: '/todos'
preLoaderRoute: typeof TodosRouteImport
parentRoute: typeof rootRouteImport
}
'/projects': {
id: '/projects'
path: '/projects'
fullPath: '/projects'
preLoaderRoute: typeof ProjectsRouteImport
parentRoute: typeof rootRouteImport
}
'/jobs': {
id: '/jobs'
path: '/jobs'
@ -68,6 +153,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof JobsRouteImport
parentRoute: typeof rootRouteImport
}
'/agenda': {
id: '/agenda'
path: '/agenda'
fullPath: '/agenda'
preLoaderRoute: typeof AgendaRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@ -75,6 +167,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/projects/': {
id: '/projects/'
path: '/'
fullPath: '/projects/'
preLoaderRoute: typeof ProjectsIndexRouteImport
parentRoute: typeof ProjectsRoute
}
'/settings/jobs': {
id: '/settings/jobs'
path: '/settings/jobs'
@ -82,12 +181,36 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsJobsRouteImport
parentRoute: typeof rootRouteImport
}
'/projects/$projectId': {
id: '/projects/$projectId'
path: '/$projectId'
fullPath: '/projects/$projectId'
preLoaderRoute: typeof ProjectsProjectIdRouteImport
parentRoute: typeof ProjectsRoute
}
}
}
interface ProjectsRouteChildren {
ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute
ProjectsIndexRoute: typeof ProjectsIndexRoute
}
const ProjectsRouteChildren: ProjectsRouteChildren = {
ProjectsProjectIdRoute: ProjectsProjectIdRoute,
ProjectsIndexRoute: ProjectsIndexRoute,
}
const ProjectsRouteWithChildren = ProjectsRoute._addFileChildren(
ProjectsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AgendaRoute: AgendaRoute,
JobsRoute: JobsRoute,
ProjectsRoute: ProjectsRouteWithChildren,
TodosRoute: TodosRoute,
SettingsJobsRoute: SettingsJobsRoute,
}
export const routeTree = rootRouteImport

View File

@ -1,5 +1,6 @@
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
import { AccentDot, Label } from "@/design";
import { BottomNav } from "@/components/shell/BottomNav";
export const Route = createRootRoute({
component: RootLayout,
@ -14,21 +15,31 @@ function RootLayout() {
<AccentDot />
<Label className="text-ink">ORDINARTHUR-OS</Label>
</Link>
<nav className="flex items-center gap-4">
{/* Nav desktop uniquement */}
<nav className="hidden md:flex items-center gap-4">
<NavLink to="/">Dashboard</NavLink>
<NavLink to="/agenda">Agenda</NavLink>
<NavLink to="/todos">Todos</NavLink>
<NavLink to="/projects">Projets</NavLink>
<NavLink to="/jobs">Jobs</NavLink>
</nav>
</div>
</header>
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-8">
{/* Padding-bottom mobile = hauteur de la BottomNav (~64px) */}
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-6 md:py-8 pb-20 md:pb-8">
<Outlet />
</main>
<footer className="border-t border-ink">
<footer className="hidden md:block 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 1</Label>
<Label>arthurbarre.fr</Label>
</div>
</footer>
<BottomNav />
</div>
);
}

View File

@ -0,0 +1,228 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { CalendarEvent, CalendarEventCreateDto } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, Label } from "@/design";
import { WeekStrip } from "@/components/agenda/WeekStrip";
import { EventCard } from "@/components/agenda/EventCard";
import { NewEventForm } from "@/components/agenda/NewEventForm";
export const Route = createFileRoute("/agenda")({ component: AgendaPage });
function AgendaPage() {
const qc = useQueryClient();
const [selectedDate, setSelectedDate] = useState(() => startOfDay(new Date()));
const [showForm, setShowForm] = useState(false);
const [syncing, setSyncing] = useState(false);
const [connecting, setConnecting] = useState(false);
const { data: googleStatus, refetch: refetchStatus } = useQuery({
queryKey: ["agenda", "google-status"],
queryFn: () => api<{ connected: boolean }>("/agenda/google/status"),
});
const isConnected = googleStatus?.connected ?? false;
// ── Borne de la semaine courante pour le fetch ──────────────────────────
const weekStart = getMonday(selectedDate);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
weekEnd.setHours(23, 59, 59, 999);
const { data: events = [], isLoading } = useQuery({
queryKey: ["agenda", weekStart.toISOString()],
queryFn: () =>
api<CalendarEvent[]>(
`/agenda/events?from=${weekStart.toISOString()}&to=${weekEnd.toISOString()}`,
),
});
const createEvent = useMutation({
mutationFn: (dto: CalendarEventCreateDto) =>
api<CalendarEvent>("/agenda/events", { method: "POST", body: JSON.stringify(dto) }),
onSuccess: () => {
setShowForm(false);
void qc.invalidateQueries({ queryKey: ["agenda"] });
},
});
const deleteEvent = useMutation({
mutationFn: (id: string) => api<void>(`/agenda/events/${id}`, { method: "DELETE" }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["agenda"] }),
});
async function handleSync() {
setSyncing(true);
try {
await api("/agenda/google/sync", { method: "POST" });
await qc.invalidateQueries({ queryKey: ["agenda"] });
void refetchStatus();
} finally {
setSyncing(false);
}
}
async function handleConnectGoogle() {
setConnecting(true);
try {
const { url } = await api<{ url: string }>("/agenda/google/oauth/start");
window.location.href = url;
} catch {
setConnecting(false);
}
}
// ── Événements du jour sélectionné ─────────────────────────────────────
const dayEvents = events
.filter((ev) => isSameDay(new Date(ev.starts_at), selectedDate))
.sort((a, b) => new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime());
const selectedLabel = selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
});
function shiftWeek(delta: number) {
const next = new Date(selectedDate);
next.setDate(next.getDate() + delta * 7);
setSelectedDate(startOfDay(next));
}
return (
<div className="space-y-6 md:space-y-10">
{/* ── En-tête ──────────────────────────────────────────────────────── */}
<section className="space-y-4">
<Label prefix="[ 04 ]">AGENDA</Label>
<BigHeading>
Agenda <em>personnel.</em>
</BigHeading>
</section>
{/* ── Bandeau semaine ──────────────────────────────────────────────── */}
<WeekStrip
selectedDate={selectedDate}
onSelect={setSelectedDate}
onPrevWeek={() => shiftWeek(-1)}
onNextWeek={() => shiftWeek(1)}
/>
{/* ── Événements du jour ───────────────────────────────────────────── */}
<section className="space-y-3">
<div className="flex items-center justify-between border-b border-ink pb-3">
<Label prefix="[ 01 ]">{capitalize(selectedLabel)}</Label>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSync}
disabled={syncing}
className="font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink transition-colors disabled:opacity-40"
>
{syncing ? "Sync…" : "↻ Google"}
</button>
<button
type="button"
onClick={() => setShowForm((v) => !v)}
className="font-mono text-[11px] uppercase tracking-label text-ink border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg transition-colors"
>
{showForm ? "Annuler" : "+ Événement"}
</button>
</div>
</div>
{showForm && (
<NewEventForm
defaultDate={selectedDate}
onSubmit={(dto) => createEvent.mutate(dto)}
onCancel={() => setShowForm(false)}
isPending={createEvent.isPending}
/>
)}
{isLoading ? (
<EmptyState text="Chargement…" />
) : dayEvents.length === 0 ? (
<EmptyState text="Aucun événement ce jour." />
) : (
<ul className="border border-ink divide-y divide-ink">
{dayEvents.map((ev) => (
<li key={ev.id}>
<EventCard
event={ev}
onDelete={(id) => deleteEvent.mutate(id)}
/>
</li>
))}
</ul>
)}
</section>
{/* ── Connexion Google ─────────────────────────────────────────────── */}
<section className="border border-ink px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Label>Google Calendar</Label>
{isConnected && (
<span className="font-mono text-[10px] uppercase tracking-label text-accent">
Connecté
</span>
)}
</div>
{isConnected ? (
<button
type="button"
onClick={handleConnectGoogle}
disabled={connecting}
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink transition-colors disabled:opacity-40"
>
{connecting ? "Redirection…" : "Reconnecter"}
</button>
) : (
<button
type="button"
onClick={handleConnectGoogle}
disabled={connecting}
className="font-mono text-[11px] uppercase tracking-label border border-ink px-3 py-1.5 text-ink hover:bg-ink hover:text-bg transition-colors disabled:opacity-40"
>
{connecting ? "Redirection…" : "Connecter →"}
</button>
)}
</section>
</div>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="border border-ink px-4 py-8">
<p className="font-sans text-sm text-muted">{text}</p>
</div>
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function startOfDay(d: Date): Date {
const c = new Date(d);
c.setHours(0, 0, 0, 0);
return c;
}
function getMonday(d: Date): Date {
const c = startOfDay(d);
const day = c.getDay() === 0 ? 6 : c.getDay() - 1;
c.setDate(c.getDate() - day);
return c;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@ -1,95 +1,88 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import type { Todo } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, DataChip, GridFrame, Label, MetaRow, SectionHeader } from "@/design";
import { BigHeading, Label } from "@/design";
import { MagicButton } from "@/components/ai/MagicButton";
import { MedsSection } from "@/components/health/MedsSection";
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,
const { data, isLoading, isError } = useQuery({
queryKey: ["todos", false],
queryFn: () => api<Todo[]>("/todos"),
});
const active = (data ?? []).filter(
(t) => t.status !== "done" && t.status !== "archived",
);
return (
<div className="space-y-12">
<section>
<Label prefix="[ 00 ]">PHASE 0 1</Label>
<BigHeading className="mt-4">
Un assistant <em>qui n'agit jamais</em> sans ton clic.
<div className="space-y-8 md:space-y-16">
<section className="space-y-6">
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
<BigHeading>
Parle. <em>Il capture.</em>
</BigHeading>
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
Le bouton enregistre ta voix, Whisper transcrit, Mistral propose des actions
rien n'est écrit tant que tu n'as pas cliqué "Confirmer".
</p>
</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 socle en place" />
<MetaRow label="PHASE 1" value="Jobs · première livraison utilisable" />
<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 className="flex justify-center">
<MagicButton />
</section>
<GridFrame cols={2}>
<div className="p-6 space-y-4">
<Label prefix="[ 03 ]">PRIORITÉ</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
La Phase 1 est la première verticale métier: ingestion backend, listing éditorial,
filtres de recherche et critères intégrés directement dans la vue jobs.
</p>
<a
href="/jobs"
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
<MedsSection />
<section className="space-y-4">
<div className="flex items-center justify-between border-b border-ink pb-3">
<Label prefix="[ 01 ]">EN COURS</Label>
<Link
to="/todos"
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink"
>
Ouvrir les jobs
</a>
Tout voir
</Link>
</div>
<div className="p-6 space-y-4">
<Label prefix="[ 04 ]">MODE</Label>
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
Les presets de critères vivent maintenant dans le header de la page jobs pour rester
au plus près du tri quotidien.
</p>
</div>
</GridFrame>
{isLoading ? (
<EmptyRow text="Chargement…" />
) : isError ? (
<EmptyRow text="Impossible de charger les todos. Vérifie le bearer token." />
) : active.length === 0 ? (
<EmptyRow text="Aucune tâche en cours. Dicte-en une." />
) : (
<ul className="border border-ink divide-y divide-ink">
{active.slice(0, 5).map((t) => (
<li key={t.id} className="px-4 py-3 flex items-center gap-3">
<span className="w-1.5 h-1.5 rounded-full bg-accent flex-shrink-0" />
<span className="font-sans text-sm text-ink truncate">{t.title}</span>
{t.ai_enriched && (
<span className="ml-auto font-mono text-[10px] uppercase tracking-label text-muted">
AI
</span>
)}
</li>
))}
{active.length > 5 && (
<li className="px-4 py-2 font-mono text-[10px] uppercase tracking-label text-muted">
+ {active.length - 5} autre{active.length - 5 !== 1 ? "s" : ""}
</li>
)}
</ul>
)}
</section>
</div>
);
}
function EmptyRow({ text }: { text: string }) {
return (
<div className="border border-ink px-4 py-6">
<p className="font-sans text-sm text-muted">{text}</p>
</div>
);
}

View File

@ -0,0 +1,183 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Project, ProjectStep, ProjectIdea } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { Label } from "@/design";
import { KanbanBoard } from "@/components/projects/KanbanBoard";
import { IdeaList } from "@/components/projects/IdeaList";
import { VoiceButton } from "@/components/ai/VoiceButton";
export const Route = createFileRoute("/projects/$projectId")({
component: ProjectDetailPage,
});
type Tab = "kanban" | "ideas";
function ProjectDetailPage() {
const { projectId } = Route.useParams();
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("kanban");
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const { data: project, isLoading: loadingProject } = useQuery<Project>({
queryKey: ["projects", projectId],
queryFn: () => api(`/projects/${projectId}`),
// The list endpoint returns all projects, so we derive from cache when possible
placeholderData: () => {
const all = queryClient.getQueryData<Project[]>(["projects"]);
return all?.find((p) => p.id === projectId);
},
});
const { data: steps = [] } = useQuery<ProjectStep[]>({
queryKey: ["projects", projectId, "steps"],
queryFn: () => api(`/projects/${projectId}/steps`),
});
const { data: ideas = [] } = useQuery<ProjectIdea[]>({
queryKey: ["projects", projectId, "ideas"],
queryFn: () => api(`/projects/${projectId}/ideas`),
});
const patchProject = useMutation({
mutationFn: (dto: { name?: string; description?: string | null }) =>
api(`/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(dto),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
setEditing(false);
},
});
function startEdit() {
setEditName(project?.name ?? "");
setEditDesc(project?.description ?? "");
setEditing(true);
}
function saveEdit() {
patchProject.mutate({
name: editName.trim() || undefined,
description: editDesc.trim() || null,
});
}
if (loadingProject && !project) {
return <p className="font-sans text-sm text-muted">Chargement</p>;
}
if (!project) {
return (
<div className="space-y-4">
<p className="font-sans text-sm text-muted">Projet introuvable.</p>
<Link to="/projects" className="font-mono text-[10px] uppercase tracking-label border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg">
Retour
</Link>
</div>
);
}
return (
<div className="space-y-6">
{/* En-tête */}
<div className="space-y-2">
<Link
to="/projects"
className="font-mono text-[9px] uppercase tracking-label text-muted hover:text-ink"
>
Projets
</Link>
{editing ? (
<div className="space-y-2 border border-ink p-3">
<input
autoFocus
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && saveEdit()}
className="w-full bg-transparent font-sans text-lg font-medium text-ink outline-none border-b border-ink focus:border-accent py-1"
/>
<textarea
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
placeholder="Description (optionnel)"
rows={2}
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
/>
<div className="flex gap-2">
<button
type="button"
onClick={saveEdit}
disabled={patchProject.isPending}
className="font-mono text-[9px] uppercase tracking-label border border-ink bg-ink text-bg px-3 py-1 disabled:opacity-40"
>
Sauvegarder
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="font-mono text-[9px] uppercase tracking-label border border-ink px-3 py-1 text-muted hover:text-ink"
>
Annuler
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-sans font-light text-2xl md:text-4xl text-ink tracking-tightest">{project.name}</h1>
{project.description && (
<p className="font-sans text-sm text-muted mt-1">{project.description}</p>
)}
</div>
<button
type="button"
onClick={startEdit}
className="flex-shrink-0 font-mono text-[9px] uppercase tracking-label border border-ink px-2 py-1 text-muted hover:text-ink"
>
Éditer
</button>
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-0 border-b border-ink">
{(["kanban", "ideas"] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className={[
"font-mono text-[10px] uppercase tracking-label px-4 py-2 border-b-2 transition-colors",
tab === t
? "border-ink text-ink"
: "border-transparent text-muted hover:text-ink",
].join(" ")}
>
{t === "kanban" ? `Kanban (${steps.length})` : `Idées (${ideas.length})`}
</button>
))}
{/* Voice button aligné à droite */}
<div className="ml-auto pb-1 flex items-center">
<VoiceButton size="sm" />
</div>
</div>
{/* Contenu */}
{tab === "kanban" ? (
<KanbanBoard projectId={projectId} steps={steps} />
) : (
<IdeaList projectId={projectId} ideas={ideas} />
)}
</div>
);
}

View File

@ -0,0 +1,201 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, ProjectCreateDto, ProjectIdea } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { Label } from "@/design";
import { cn } from "@/lib/cn";
export const Route = createFileRoute("/projects/")({
component: ProjectsPage,
});
function ProjectsPage() {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({ name: "", description: "" });
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ["projects"],
queryFn: () => api("/projects"),
});
const createProject = useMutation({
mutationFn: (dto: ProjectCreateDto) =>
api("/projects", { method: "POST", body: JSON.stringify(dto) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
setCreating(false);
setForm({ name: "", description: "" });
},
});
const archiveProject = useMutation({
mutationFn: (id: string) =>
api(`/projects/${id}`, { method: "PATCH", body: JSON.stringify({ status: "archived" }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
const active = projects.filter((p) => p.status === "active");
const archived = projects.filter((p) => p.status === "archived");
function submit() {
const name = form.name.trim();
if (!name) return;
createProject.mutate({ name, description: form.description.trim() || null, status: "active" });
}
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="font-sans font-light text-3xl md:text-5xl text-ink tracking-tightest">Projets</h1>
<button
type="button"
onClick={() => setCreating((v) => !v)}
className="font-mono text-[10px] uppercase tracking-label border border-ink px-3 py-1.5 hover:bg-ink hover:text-bg transition-colors"
>
{creating ? "Annuler" : "+ Nouveau"}
</button>
</div>
{creating && (
<div className="border border-ink p-4 space-y-3">
<Label>NOUVEAU PROJET</Label>
<input
autoFocus
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
onKeyDown={(e) => e.key === "Enter" && submit()}
placeholder="Nom du projet"
className="w-full bg-transparent font-sans text-sm text-ink outline-none border-b border-ink focus:border-accent py-1"
/>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Description (optionnel)"
rows={2}
className="w-full bg-transparent font-sans text-sm text-ink outline-none resize-none border-b border-ink focus:border-accent py-1"
/>
<button
type="button"
onClick={submit}
disabled={!form.name.trim() || createProject.isPending}
className="font-mono text-[10px] uppercase tracking-label border border-ink bg-ink text-bg px-4 py-2 hover:bg-accent hover:border-accent disabled:opacity-40"
>
{createProject.isPending ? "Création…" : "Créer"}
</button>
</div>
)}
{isLoading ? (
<p className="font-sans text-sm text-muted">Chargement</p>
) : active.length === 0 && !creating ? (
<p className="font-sans text-sm text-muted">Aucun projet actif. Crée-en un !</p>
) : (
<ul className="space-y-px">
{active.map((p) => (
<ProjectRow key={p.id} project={p} onArchive={() => archiveProject.mutate(p.id)} />
))}
</ul>
)}
{archived.length > 0 && (
<div className="space-y-2">
<Label className="text-muted">ARCHIVÉS ({archived.length})</Label>
<ul className="space-y-px opacity-50">
{archived.map((p) => (
<ProjectRow key={p.id} project={p} />
))}
</ul>
</div>
)}
<FreeIdeasSection />
</div>
);
}
const PRIORITY_LABELS = ["Bas", "Normal", "Haut", "Urgent"] as const;
function FreeIdeasSection() {
const queryClient = useQueryClient();
const { data: ideas = [] } = useQuery<ProjectIdea[]>({
queryKey: ["ideas", "free"],
queryFn: () => api("/projects/ideas"),
});
const deleteIdea = useMutation({
mutationFn: (id: string) => api(`/projects/ideas/${id}`, { method: "DELETE" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["ideas", "free"] }),
});
if (ideas.length === 0) return null;
const sorted = [...ideas].sort((a, b) => (b.priority ?? -1) - (a.priority ?? -1));
return (
<div className="space-y-3 border-t border-ink pt-6">
<Label>IDÉES LIBRES ({ideas.length})</Label>
<ul className="space-y-2">
{sorted.map((idea) => (
<li key={idea.id} className="border border-ink px-3 py-2 flex items-start gap-3 group">
{idea.priority !== null && (
<span className={cn(
"flex-shrink-0 font-mono text-[9px] uppercase tracking-label mt-0.5 px-1",
idea.priority === 3 ? "bg-accent text-bg" :
idea.priority === 2 ? "border border-ink text-ink" :
"text-muted",
)}>
{PRIORITY_LABELS[idea.priority]}
</span>
)}
<span className="flex-1 font-sans text-sm text-ink leading-snug">{idea.content}</span>
<button
type="button"
onClick={() => deleteIdea.mutate(idea.id)}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-opacity"
>
×
</button>
</li>
))}
</ul>
</div>
);
}
function ProjectRow({ project, onArchive }: { project: Project; onArchive?: () => void }) {
return (
<li className="border border-ink group flex items-center">
<Link
to="/projects/$projectId"
params={{ projectId: project.id }}
className="flex-1 px-4 py-3 hover:bg-ink hover:text-bg transition-colors"
>
<div className="flex items-baseline gap-3">
<span className="font-mono text-[10px] uppercase tracking-label text-accent"></span>
<span className="font-sans text-sm font-medium text-ink group-hover:text-bg">{project.name}</span>
{project.description && (
<span className="font-sans text-xs text-muted group-hover:text-bg/70 truncate hidden sm:block">
{project.description}
</span>
)}
</div>
</Link>
{onArchive && (
<button
type="button"
onClick={onArchive}
className="px-3 py-3 opacity-0 group-hover:opacity-100 font-mono text-[10px] text-muted hover:text-accent transition-all"
aria-label="Archiver"
>
Archiver
</button>
)}
</li>
);
}

View File

@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/projects")({
component: () => <Outlet />,
});

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Todo, TodoCreateDto, TodoPatchDto, TodoStatus } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, Label, SectionHeader } from "@/design";
export const Route = createFileRoute("/todos")({ component: TodosPage });
function TodosPage() {
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [showDone, setShowDone] = useState(false);
const todos = useQuery({
queryKey: ["todos", showDone],
queryFn: () =>
api<Todo[]>(showDone ? "/todos?status=done" : "/todos"),
});
const createTodo = useMutation({
mutationFn: (payload: TodoCreateDto) =>
api<Todo>("/todos", { method: "POST", body: JSON.stringify(payload) }),
onSuccess: () => {
setInput("");
void queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
const patchTodo = useMutation({
mutationFn: ({ id, patch }: { id: string; patch: TodoPatchDto }) =>
api<Todo>(`/todos/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
const deleteTodo = useMutation({
mutationFn: (id: string) => api<void>(`/todos/${id}`, { method: "DELETE" }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const title = input.trim();
if (!title) return;
createTodo.mutate({ title, status: "inbox", tags: [], checklist: [], verification_steps: [] });
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
}
const data = todos.data ?? [];
const active = data.filter((t) => t.status !== "done" && t.status !== "archived");
const done = data.filter((t) => t.status === "done");
return (
<div className="space-y-6 md:space-y-10">
<section className="space-y-4">
<Label prefix="[ 02 ]">PHASE 2 · TODOS</Label>
<BigHeading>
Capture <em>rapide</em>, traitement via le magic button.
</BigHeading>
<p className="max-w-2xl font-sans text-sm leading-6 text-ink">
Écris une tâche, appuie sur Entrée. Le reste (priorité, contexte, tags) sera rempli par
le bouton vocal en Phase 5.
</p>
</section>
<SectionHeader number="01" label="CAPTURE" title="Ajoute une tâche" />
<form onSubmit={handleSubmit} className="border border-ink">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Relancer Paul… (Entrée pour valider, Shift+Entrée pour sauter une ligne)"
rows={3}
className="w-full bg-transparent px-4 py-3 font-sans text-sm text-ink outline-none placeholder:text-muted resize-none"
/>
<div className="border-t border-ink px-4 py-2 flex justify-end">
<button
type="submit"
disabled={!input.trim() || createTodo.isPending}
className="border border-ink px-4 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg disabled:opacity-40"
>
Ajouter
</button>
</div>
</form>
<SectionHeader
number="02"
label="LISTE"
title={todos.isLoading ? "Chargement…" : `${active.length} tâche${active.length !== 1 ? "s" : ""} en cours`}
/>
<div className="border border-ink divide-y divide-ink">
{todos.isLoading ? (
<EmptyState text="Chargement…" />
) : todos.isError ? (
<EmptyState text="Impossible de charger les todos. Vérifie l'API." />
) : active.length === 0 ? (
<EmptyState text="Aucune tâche en cours. Capture la prochaine ci-dessus." />
) : (
active.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onToggleDone={() =>
patchTodo.mutate({
id: todo.id,
patch: { status: "done" },
})
}
onDelete={() => deleteTodo.mutate(todo.id)}
/>
))
)}
</div>
{/* Section "faites" collapsible */}
{(done.length > 0 || showDone) && (
<>
<button
type="button"
onClick={() => setShowDone((v) => !v)}
className="font-mono text-[11px] uppercase tracking-label text-ink border border-ink px-4 py-2 hover:bg-ink hover:text-bg transition-colors"
>
{showDone ? "Masquer les faites" : `Voir les faites (${done.length})`}
</button>
{showDone && (
<div className="border border-ink divide-y divide-ink opacity-60">
{done.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onToggleDone={() =>
patchTodo.mutate({ id: todo.id, patch: { status: "inbox" } })
}
onDelete={() => deleteTodo.mutate(todo.id)}
isDone
/>
))}
</div>
)}
</>
)}
</div>
);
}
function TodoRow({
todo,
onToggleDone,
onDelete,
isDone = false,
}: {
todo: Todo;
onToggleDone: () => void;
onDelete: () => void;
isDone?: boolean;
}) {
const [title, setTitle] = useState(todo.title);
return (
<article className="flex items-start gap-3 px-4 py-3">
{/* Checkbox */}
<button
type="button"
onClick={onToggleDone}
aria-label={isDone ? "Rouvrir" : "Marquer comme fait"}
className={[
"mt-0.5 flex-shrink-0 w-4 h-4 border border-ink transition-colors",
isDone ? "bg-ink" : "hover:bg-ink/20",
].join(" ")}
/>
{/* Titre éditable */}
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== todo.title) {
// patch silencieux via fetch direct pour ne pas invalider inutilement
void api<Todo>(`/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify({ title: next } satisfies TodoPatchDto),
});
}
}}
className={[
"flex-1 min-w-0 bg-transparent font-sans text-sm text-ink outline-none border-b border-transparent focus:border-ink",
isDone ? "line-through text-muted" : "",
].join(" ")}
/>
{/* Supprimer */}
<button
type="button"
onClick={onDelete}
aria-label="Supprimer"
className="flex-shrink-0 font-mono text-[10px] uppercase tracking-label text-muted hover:text-ink transition-colors px-1"
>
×
</button>
</article>
);
}
function EmptyState({ text }: { text: string }) {
return (
<div className="px-4 py-8">
<p className="font-sans text-sm text-muted">{text}</p>
</div>
);
}

View File

@ -2,12 +2,23 @@
Manifests Kubernetes pour le k3s perso d'Arthur.
## Stack
- `postgres.yaml` — StatefulSet Postgres 16 + PVC 5 Gi (single-user, faible volume).
- `api.deployment.yaml` / `pwa.deployment.yaml` — services applicatifs.
- `ingress.yaml` — routage Traefik TLS vers `os.arthurbarre.fr` et `api.os.arthurbarre.fr`.
- `migrate.job.yaml` — job one-shot `drizzle-orm` pour appliquer les migrations.
- `backup.cronjob.yaml``pg_dump` quotidien vers un bucket S3-compatible via `rclone`.
## 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 postgres.yaml
# Attendre que le pod postgres soit Ready.
kubectl apply -f migrate.job.yaml # crée le schéma ordinarthur_os + tables
kubectl apply -f api.deployment.yaml
kubectl apply -f pwa.deployment.yaml
kubectl apply -f ingress.yaml
@ -20,5 +31,7 @@ kubectl apply -f backup.cronjob.yaml
- 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
- StorageClass du PVC postgres (k3s fournit `local-path` par défaut — OK pour single-node)
- Bucket S3-compatible pour les backups (B2 / Scaleway / autre)
- Image registry : Gitea CR (défaut) — credentials pull peuvent nécessiter un `imagePullSecrets`
- Image pour le `migrate.job` : soit re-utiliser l'image API et `node ./node_modules/.../migrate.ts` via tsx, soit builder une image dédiée `ordinarthur-os-migrate` qui embarque `packages/db` compilé.

View File

@ -9,9 +9,11 @@ spec:
template:
metadata: { labels: { app: api } }
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: api
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
imagePullPolicy: Always
ports: [{ containerPort: 3000 }]
envFrom:
@ -19,6 +21,7 @@ spec:
env:
- { name: NODE_ENV, value: production }
- { name: PORT, value: "3000" }
- { name: PWA_URL, value: "https://os.arthurbarre.fr" }
readinessProbe:
httpGet: { path: /health, port: 3000 }
initialDelaySeconds: 5
@ -37,5 +40,9 @@ metadata:
name: api
namespace: ordinarthur-os
spec:
type: NodePort
selector: { app: api }
ports: [{ port: 3000, targetPort: 3000 }]
ports:
- port: 3000
targetPort: 3000
nodePort: 30100

View File

@ -1,6 +1,7 @@
# 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).
# Backup quotidien du Postgres ordinarthur-os.
# Secrets dans `ordinarthur-os-backup-secrets` :
# PGURL → chaîne `postgres://…` (copie de DATABASE_URL)
# RCLONE_REMOTE → ex. `b2:ordinarthur-os-backups`
apiVersion: batch/v1
kind: CronJob
metadata:

View File

@ -1,27 +0,0 @@
# À 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 } } }

View File

@ -0,0 +1,37 @@
# Relancer après chaque déploiement qui contient une migration :
# kubectl -n ordinarthur-os delete job migrate --ignore-not-found
# kubectl -n ordinarthur-os apply -f deploy/k8s/migrate.job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
namespace: ordinarthur-os
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: OnFailure
imagePullSecrets:
- name: gitea-registry
containers:
- name: migrate
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
imagePullPolicy: Always
envFrom:
- secretRef: { name: ordinarthur-os-secrets }
# Drizzle migrator via node inline — les migrations SQL sont dans /app/packages/db/migrations
command:
- node
- -e
- |
const { createDb } = require('@ordinarthur-os/db');
const { migrate } = require('drizzle-orm/postgres-js/migrator');
const path = require('path');
const { db, close } = createDb(process.env.DATABASE_URL);
migrate(db, { migrationsFolder: path.join('/app', 'packages/db/migrations') })
.then(() => { console.log('[migrate] done'); return close(); })
.catch(e => { console.error(e); process.exit(1); });
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 300m, memory: 256Mi }

66
deploy/k8s/postgres.yaml Normal file
View File

@ -0,0 +1,66 @@
# Postgres standalone pour ordinarthur-os.
# Single-user, faible volume → 1 replica + PVC. Backup via backup.cronjob.yaml.
#
# Secrets attendus dans `ordinarthur-os-db-secrets` (cf. secrets.template.yaml) :
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
#
# `DATABASE_URL` consommé par l'API est injecté depuis `ordinarthur-os-secrets`
# et doit pointer vers `postgres.ordinarthur-os.svc.cluster.local:5432/<POSTGRES_DB>`.
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: ordinarthur-os
spec:
clusterIP: None
selector: { app: postgres }
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
namespace: ordinarthur-os
spec:
serviceName: postgres
replicas: 1
selector: { matchLabels: { app: postgres } }
template:
metadata: { labels: { app: postgres } }
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
envFrom:
- secretRef: { name: ordinarthur-os-db-secrets }
env:
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec: { command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec: { command: ["pg_isready", "-U", "$(POSTGRES_USER)", "-d", "$(POSTGRES_DB)"] }
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 1000m, memory: 512Mi }
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests: { storage: 5Gi }
# storageClassName: à définir selon le cluster (local-path par défaut sur k3s)

View File

@ -9,14 +9,17 @@ spec:
template:
metadata: { labels: { app: pwa } }
spec:
imagePullSecrets:
- name: gitea-registry
containers:
- name: pwa
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:latest
imagePullPolicy: Always
ports: [{ containerPort: 80 }]
readinessProbe:
httpGet: { path: /, port: 80 }
initialDelaySeconds: 3
periodSeconds: 10
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { cpu: 100m, memory: 128Mi }
@ -27,5 +30,9 @@ metadata:
name: pwa
namespace: ordinarthur-os
spec:
type: NodePort
selector: { app: pwa }
ports: [{ port: 80, targetPort: 80 }]
ports:
- port: 80
targetPort: 80
nodePort: 30099

View File

@ -1,6 +1,7 @@
# NE PAS COMMITER LES VRAIES VALEURS.
# Ce fichier est un template — appliquer une copie remplie via :
# Dupliquer, remplir, puis :
# kubectl -n ordinarthur-os apply -f secrets.yaml
---
apiVersion: v1
kind: Secret
metadata:
@ -9,9 +10,7 @@ metadata:
type: Opaque
stringData:
API_BEARER_TOKEN: ""
SUPABASE_URL: "https://supabase.arthurbarre.fr"
SUPABASE_SERVICE_ROLE_KEY: ""
SUPABASE_SCHEMA: "ordinarthur_os"
DATABASE_URL: "postgres://ordinarthur:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os"
MISTRAL_API_KEY: ""
MISTRAL_MODEL: "mistral-small-latest"
GROQ_API_KEY: ""
@ -22,3 +21,14 @@ stringData:
ICAL_FEED_SECRET: ""
TELEGRAM_BOT_TOKEN: ""
TELEGRAM_WEBHOOK_SECRET: ""
---
apiVersion: v1
kind: Secret
metadata:
name: ordinarthur-os-db-secrets
namespace: ordinarthur-os
type: Opaque
stringData:
POSTGRES_USER: "ordinarthur"
POSTGRES_PASSWORD: ""
POSTGRES_DB: "ordinarthur_os"

35
docker-compose.yml Normal file
View File

@ -0,0 +1,35 @@
# Compose dev-only pour ordinarthur-os.
# Pas destiné à la prod (la prod tourne sur k3s via deploy/k8s/postgres.yaml).
#
# Usage :
# docker compose up -d # lance Postgres sur :5432
# pnpm --filter @ordinarthur-os/db migrate # applique les migrations
# docker compose logs -f postgres # suivre les logs
# docker compose down # stop (garde le volume)
# docker compose down -v # stop + wipe data
#
# Les credentials matchent `apps/api/.env.example` pour que `DATABASE_URL`
# fonctionne sans config supplémentaire.
services:
postgres:
image: postgres:16-alpine
container_name: ordinarthur-os-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: ordinarthur
POSTGRES_PASSWORD: changeme
POSTGRES_DB: ordinarthur_os
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ordinarthur -d ordinarthur_os"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres-data:
name: ordinarthur-os-postgres-data

View File

@ -1,15 +1,46 @@
# @ordinarthur-os/db
Migrations SQL versionnées pour le schéma `ordinarthur_os` sur Supabase self-hosted.
Schéma Postgres + migrations versionnées pour ordinarthur-os, via [Drizzle ORM](https://orm.drizzle.team).
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`).
La base tourne dans le cluster k3s d'Arthur (cf. [`deploy/k8s/postgres.yaml`](../../deploy/k8s/postgres.yaml)). Plus de Supabase — l'API NestJS parle directement à Postgres.
## Ordre
## Arbo
- `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)
- `src/schema/` — définitions Drizzle (TypeScript). Une table = un fichier, réexporté depuis `schema/index.ts`.
- `src/client.ts` — factory `createDb(connectionString)` utilisée par l'API.
- `src/migrate.ts` — runner des migrations (consomme `DATABASE_URL`).
- `drizzle.config.ts` — config drizzle-kit.
- `migrations/` — SQL versionné (`0000_init.sql`, `0001_jobs.sql`, …) + `meta/_journal.json` exploité par le runner.
## Commandes
Depuis la racine du monorepo :
```bash
# 0. Lancer le Postgres de dev (cf. docker-compose.yml à la racine).
docker compose up -d
# 1. Générer un diff SQL à partir du schéma TS (nouvelle migration).
pnpm --filter @ordinarthur-os/db generate
# 2. Appliquer les migrations pendantes sur la base pointée par DATABASE_URL.
# Le .env de apps/api suffit : le script lit process.env.DATABASE_URL.
pnpm --filter @ordinarthur-os/db migrate
# 3. Ouvrir Drizzle Studio (inspection UI).
pnpm --filter @ordinarthur-os/db studio
```
## Convention migrations
- `0000_init.sql` — extension `pgcrypto` + création du schéma `ordinarthur_os`. Idempotent (à ne PAS régénérer via drizzle-kit).
- `0001_jobs.sql` — tables `jobs` + `job_search_criteria` (Phase 1).
- `0002_todos.sql``todos` + `client_mutations` (Phase 2).
- `0003_projects.sql``projects`, `project_steps`, `project_ideas` (Phase 3).
- `0004_agenda.sql``calendar_events`, `google_oauth_tokens` (Phase 4).
- `0005_ai.sql``ai_actions` (Phase 5).
- `0006_health.sql``daily_checkins` (Phase 7).
## Plus de RLS
Contrairement au setup Supabase initial, la DB n'est **pas** exposée publiquement : le seul consommateur est l'API NestJS, protégée par bearer token. On ne déploie donc aucune policy RLS — c'est du pur isolement réseau (service ClusterIP interne) + contrôle d'accès Postgres classique.

View File

@ -0,0 +1,22 @@
import type { Config } from "drizzle-kit";
/**
* Config drizzle-kit.
*
* Usage :
* - `pnpm --filter @ordinarthur-os/db generate` génère un SQL diff dans `migrations/`
* - `pnpm --filter @ordinarthur-os/db migrate` applique les migrations pendantes
*
* `DATABASE_URL` attendu au format `postgres://user:pass@host:5432/dbname`.
*/
export default {
schema: "./src/schema/index.ts",
out: "./migrations",
dialect: "postgresql",
schemaFilter: ["ordinarthur_os"],
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
strict: true,
verbose: true,
} satisfies Config;

View File

@ -0,0 +1,10 @@
-- 0000_init.sql — Phase 0
-- Pose le socle: extension pgcrypto + schéma dédié.
-- Ces instructions sont idempotentes et sûres à ré-exécuter.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS "ordinarthur_os";
COMMENT ON SCHEMA "ordinarthur_os" IS
'Assistant personnel single-user d''Arthur. Accès applicatif exclusivement via l''API NestJS.';

View File

@ -0,0 +1,51 @@
-- 0001_jobs.sql — Phase 1
-- Tables jobs + job_search_criteria.
CREATE TABLE IF NOT EXISTS "ordinarthur_os"."jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source" text NOT NULL,
"source_url" text NOT NULL,
"title" text NOT NULL,
"company" text,
"description" text,
"location" text,
"remote_type" text,
"salary_min" integer,
"salary_max" integer,
"stack" text[] DEFAULT '{}'::text[] NOT NULL,
"apply_url" text,
"first_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"starred" boolean DEFAULT false NOT NULL,
"applied_at" timestamp with time zone,
"notes" text,
CONSTRAINT "jobs_source_url_unique" UNIQUE("source_url"),
CONSTRAINT "jobs_remote_type_check"
CHECK ("remote_type" IS NULL OR "remote_type" IN ('remote','hybrid','onsite'))
);
CREATE INDEX IF NOT EXISTS "jobs_last_seen_idx"
ON "ordinarthur_os"."jobs" ("last_seen_at" DESC);
CREATE INDEX IF NOT EXISTS "jobs_archived_idx"
ON "ordinarthur_os"."jobs" ("archived");
CREATE INDEX IF NOT EXISTS "jobs_remote_type_idx"
ON "ordinarthur_os"."jobs" ("remote_type");
CREATE INDEX IF NOT EXISTS "jobs_stack_gin"
ON "ordinarthur_os"."jobs" USING gin ("stack");
CREATE TABLE IF NOT EXISTS "ordinarthur_os"."job_search_criteria" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"titles" text[] DEFAULT '{}'::text[] NOT NULL,
"locations" text[] DEFAULT '{}'::text[] NOT NULL,
"stack" text[] DEFAULT '{}'::text[] NOT NULL,
"remote_types" text[] DEFAULT '{}'::text[] NOT NULL,
"salary_min" integer,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "job_criteria_active_idx"
ON "ordinarthur_os"."job_search_criteria" ("active");

View File

@ -1,20 +0,0 @@
-- 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.)

View File

@ -1,54 +0,0 @@
-- 0002_jobs.sql — Phase 1
-- Tables: jobs, job_search_criteria
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.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[] not null default '{}',
apply_url text,
first_seen_at timestamptz not null default now(),
last_seen_at timestamptz not null default now(),
archived boolean not null default false,
starred boolean not null default false,
applied_at timestamptz,
notes text
);
create index if not exists jobs_last_seen_idx on ordinarthur_os.jobs(last_seen_at desc);
create index if not exists jobs_archived_idx on ordinarthur_os.jobs(archived);
create index if not exists jobs_remote_type_idx on ordinarthur_os.jobs(remote_type);
create index if not exists jobs_stack_gin on ordinarthur_os.jobs using gin (stack);
alter table ordinarthur_os.jobs enable row level security;
drop policy if exists jobs_service_role on ordinarthur_os.jobs;
create policy jobs_service_role on ordinarthur_os.jobs
for all using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');
create table if not exists ordinarthur_os.job_search_criteria (
id uuid primary key default gen_random_uuid(),
name text,
titles text[] not null default '{}',
locations text[] not null default '{}',
stack text[] not null default '{}',
remote_types text[] not null default '{}',
salary_min int,
active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists job_criteria_active_idx on ordinarthur_os.job_search_criteria(active);
alter table ordinarthur_os.job_search_criteria enable row level security;
drop policy if exists job_criteria_service_role on ordinarthur_os.job_search_criteria;
create policy job_criteria_service_role on ordinarthur_os.job_search_criteria
for all using (auth.role() = 'service_role')
with check (auth.role() = 'service_role');

View File

@ -0,0 +1,27 @@
-- 0002_todos.sql — Phase 2
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.todos (
id uuid primary key default gen_random_uuid(),
title text not null,
description text,
status text not null default 'inbox'
check (status in ('inbox', 'todo', 'doing', 'done', 'archived')),
priority smallint check (priority between 0 and 3),
due_at timestamptz,
tags text[] not null default '{}',
project_id uuid,
checklist jsonb not null default '[]'::jsonb,
energy text check (energy in ('low', 'med', 'high')),
context text,
recurrence text,
ticket_url text,
verification_steps text[] not null default '{}',
ai_enriched boolean not null default false,
created_at timestamptz not null default now(),
completed_at timestamptz
);
create index if not exists todos_status_idx on ordinarthur_os.todos(status);
create index if not exists todos_due_at_idx on ordinarthur_os.todos(due_at);
create index if not exists todos_tags_gin on ordinarthur_os.todos using gin (tags);

View File

@ -0,0 +1,20 @@
-- 0003_ai_actions.sql — Phase 5
-- Audit log des intentions IA (texte/voix) + du résultat d'exécution.
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.ai_actions (
id uuid primary key default gen_random_uuid(),
input_text text, -- ce que l'utilisateur a tapé (null si voice)
transcript text, -- transcription Whisper (null si text)
function_name text, -- nom de la fonction proposée par Mistral
function_args jsonb, -- arguments de la fonction
result jsonb, -- row(s) créé(s) / payload de retour
status text not null default 'proposed'
check (status in ('proposed', 'confirmed', 'cancelled', 'failed')),
error text, -- message d'erreur si status='failed'
created_at timestamptz not null default now(),
confirmed_at timestamptz
);
create index if not exists ai_actions_status_idx on ordinarthur_os.ai_actions(status);
create index if not exists ai_actions_created_at_idx on ordinarthur_os.ai_actions(created_at desc);

View File

@ -0,0 +1,10 @@
-- 0004_daily_checkins.sql — Phase 7 (partiel, avancé pour le toggle médocs home)
-- 1 ligne par jour : médicaments pris + note libre.
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.daily_checkins (
day date primary key,
meds_taken boolean not null default false,
note text,
updated_at timestamptz not null default now()
);

View File

@ -0,0 +1,29 @@
-- 0005_agenda.sql — Phase 4
-- Événements calendrier (source locale ou sync Google) + token OAuth Google (single-row).
set search_path to ordinarthur_os, public;
create table if not exists ordinarthur_os.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 not null default false,
source text not null default 'ordinarthur-os',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists calendar_events_starts_at_idx
on ordinarthur_os.calendar_events(starts_at);
-- Single-row pour Arthur (id forcé à 1 par le check)
create table if not exists ordinarthur_os.google_oauth_tokens (
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
);

View File

@ -0,0 +1,31 @@
-- Phase 3 — Projects, Steps, Ideas
CREATE TABLE ordinarthur_os.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ordinarthur_os.project_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES ordinarthur_os.projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ordinarthur_os.project_ideas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES ordinarthur_os.projects(id) ON DELETE CASCADE,
content TEXT NOT NULL,
priority SMALLINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX project_steps_project_id_idx ON ordinarthur_os.project_steps(project_id);
CREATE INDEX project_ideas_project_id_idx ON ordinarthur_os.project_ideas(project_id);

View File

@ -0,0 +1,3 @@
-- Phase 3 — capture_idea : project_id devient nullable (idées libres sans projet)
ALTER TABLE ordinarthur_os.project_ideas
ALTER COLUMN project_id DROP NOT NULL;

View File

@ -0,0 +1,62 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1744848000000,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1744848060000,
"tag": "0001_jobs",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1744934400000,
"tag": "0002_todos",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1745020800000,
"tag": "0003_ai_actions",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1745107200000,
"tag": "0004_daily_checkins",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1745193600000,
"tag": "0005_agenda",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1745280000000,
"tag": "0006_projects",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1745366400000,
"tag": "0007_ideas_nullable_project",
"breakpoints": true
}
]
}

View File

@ -2,7 +2,30 @@
"name": "@ordinarthur-os/db",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"migrate:print": "ls migrations"
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"generate": "drizzle-kit generate",
"migrate": "tsx --env-file=../../apps/api/.env src/migrate.ts",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.4",
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/node": "^20.16.10",
"drizzle-kit": "^0.28.1",
"tsx": "^4.19.2",
"typescript": "^5.5.4"
}
}

31
packages/db/src/client.ts Normal file
View File

@ -0,0 +1,31 @@
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres, { type Sql } from "postgres";
import * as schema from "./schema";
export type Db = PostgresJsDatabase<typeof schema>;
export interface DbHandle {
db: Db;
sql: Sql;
close: () => Promise<void>;
}
/**
* Crée un client Drizzle connecté à Postgres.
*
* - `max` à 10 connexions (single-user, trafic très faible).
* - `prepare: false` pour éviter les conflits avec les prepared statements côté
* pool k3s (pas indispensable ici mais sans coût mesurable).
*/
export function createDb(connectionString: string): DbHandle {
const sql = postgres(connectionString, {
max: 10,
prepare: false,
});
const db = drizzle(sql, { schema });
return {
db,
sql,
close: () => sql.end({ timeout: 5 }),
};
}

3
packages/db/src/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * as schema from "./schema/index";
export { appSchema } from "./schema/index";
export { createDb, type Db, type DbHandle } from "./client";

View File

@ -0,0 +1,30 @@
/**
* Exécute les migrations Drizzle pendantes.
*
* DATABASE_URL=postgres://... pnpm --filter @ordinarthur-os/db migrate
*/
import * as path from "node:path";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { createDb } from "./client";
async function main() {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("[migrate] DATABASE_URL manquant");
process.exit(1);
}
const { db, close } = createDb(url);
try {
console.log("[migrate] application des migrations…");
await migrate(db, { migrationsFolder: path.resolve(__dirname, "..", "migrations") });
console.log("[migrate] ✓ à jour");
} finally {
await close();
}
}
main().catch((err) => {
console.error("[migrate] échec:", err);
process.exit(1);
});

View File

@ -0,0 +1,8 @@
import { pgSchema } from "drizzle-orm/pg-core";
/**
* Schéma Postgres dédié. Toutes les tables métier vivent ici.
* L'extension pgcrypto (pour gen_random_uuid()) et le schéma lui-même sont
* créés par la première migration SQL (cf. packages/db/migrations/0000_init.sql).
*/
export const appSchema = pgSchema("ordinarthur_os");

View File

@ -0,0 +1,26 @@
import { sql } from "drizzle-orm";
import { index, jsonb, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const aiActions = appSchema.table(
"ai_actions",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
inputText: text("input_text"),
transcript: text("transcript"),
functionName: text("function_name"),
functionArgs: jsonb("function_args"),
result: jsonb("result"),
status: text("status").notNull().default("proposed"),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
},
(t) => ({
statusIdx: index("ai_actions_status_idx").on(t.status),
createdAtIdx: index("ai_actions_created_at_idx").on(t.createdAt),
}),
);
export type AiActionRow = typeof aiActions.$inferSelect;
export type AiActionInsert = typeof aiActions.$inferInsert;

View File

@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import { boolean, index, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const calendarEvents = appSchema.table(
"calendar_events",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
googleEventId: text("google_event_id").unique(),
title: text("title").notNull(),
description: text("description"),
location: text("location"),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
allDay: boolean("all_day").notNull().default(false),
source: text("source").notNull().default("ordinarthur-os"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
startsAtIdx: index("calendar_events_starts_at_idx").on(t.startsAt),
}),
);
export const googleOAuthTokens = appSchema.table("google_oauth_tokens", {
id: smallint("id").primaryKey().default(1),
accessToken: text("access_token").notNull(),
refreshToken: text("refresh_token").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
calendarId: text("calendar_id"),
});
export type CalendarEventRow = typeof calendarEvents.$inferSelect;
export type CalendarEventInsert = typeof calendarEvents.$inferInsert;
export type GoogleOAuthTokenRow = typeof googleOAuthTokens.$inferSelect;

View File

@ -0,0 +1,12 @@
import { boolean, date, text, timestamp } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const dailyCheckins = appSchema.table("daily_checkins", {
day: date("day").primaryKey(),
medsTaken: boolean("meds_taken").notNull().default(false),
note: text("note"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export type DailyCheckinRow = typeof dailyCheckins.$inferSelect;
export type DailyCheckinInsert = typeof dailyCheckins.$inferInsert;

View File

@ -0,0 +1,7 @@
export { appSchema } from "./_schema";
export * from "./jobs";
export * from "./todos";
export * from "./ai_actions";
export * from "./daily_checkins";
export * from "./calendar_events";
export * from "./projects";

View File

@ -0,0 +1,67 @@
import { sql } from "drizzle-orm";
import { boolean, index, integer, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
// ---------------------------------------------------------------------------
// jobs
// ---------------------------------------------------------------------------
export const jobs = appSchema.table(
"jobs",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
source: text("source").notNull(), // 'Indeed','WeLoveDevs',...
sourceUrl: text("source_url").notNull().unique(), // clé de dedup
title: text("title").notNull(),
company: text("company"),
description: text("description"),
location: text("location"),
// 'remote' | 'hybrid' | 'onsite' — contraint côté applicatif (zod)
remoteType: text("remote_type"),
salaryMin: integer("salary_min"),
salaryMax: integer("salary_max"),
stack: text("stack").array().notNull().default(sql`'{}'::text[]`),
applyUrl: text("apply_url"),
firstSeenAt: timestamp("first_seen_at", { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
archived: boolean("archived").notNull().default(false),
starred: boolean("starred").notNull().default(false),
appliedAt: timestamp("applied_at", { withTimezone: true }),
notes: text("notes"),
},
(t) => ({
lastSeenIdx: index("jobs_last_seen_idx").on(t.lastSeenAt.desc()),
archivedIdx: index("jobs_archived_idx").on(t.archived),
remoteTypeIdx: index("jobs_remote_type_idx").on(t.remoteType),
stackGin: index("jobs_stack_gin").using("gin", t.stack),
}),
);
export type JobRow = typeof jobs.$inferSelect;
export type JobInsert = typeof jobs.$inferInsert;
// ---------------------------------------------------------------------------
// job_search_criteria
// ---------------------------------------------------------------------------
export const jobSearchCriteria = appSchema.table(
"job_search_criteria",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("name"),
titles: text("titles").array().notNull().default(sql`'{}'::text[]`),
locations: text("locations").array().notNull().default(sql`'{}'::text[]`),
stack: text("stack").array().notNull().default(sql`'{}'::text[]`),
remoteTypes: text("remote_types").array().notNull().default(sql`'{}'::text[]`),
salaryMin: integer("salary_min"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
activeIdx: index("job_criteria_active_idx").on(t.active),
}),
);
export type JobSearchCriteriaRow = typeof jobSearchCriteria.$inferSelect;
export type JobSearchCriteriaInsert = typeof jobSearchCriteria.$inferInsert;

View File

@ -0,0 +1,47 @@
import { sql } from "drizzle-orm";
import { index, real, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const projects = appSchema.table("projects", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
name: text("name").notNull(),
description: text("description"),
status: text("status").notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const projectSteps = appSchema.table(
"project_steps",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
title: text("title").notNull(),
status: text("status").notNull().default("backlog"),
position: real("position").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
projectIdIdx: index("project_steps_project_id_idx").on(t.projectId),
}),
);
export const projectIdeas = appSchema.table(
"project_ideas",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
content: text("content").notNull(),
priority: smallint("priority"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
projectIdIdx: index("project_ideas_project_id_idx").on(t.projectId),
}),
);
export type ProjectRow = typeof projects.$inferSelect;
export type ProjectInsert = typeof projects.$inferInsert;
export type ProjectStepRow = typeof projectSteps.$inferSelect;
export type ProjectIdeaRow = typeof projectIdeas.$inferSelect;

View File

@ -0,0 +1,34 @@
import { sql } from "drizzle-orm";
import { boolean, index, jsonb, smallint, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { appSchema } from "./_schema";
export const todos = appSchema.table(
"todos",
{
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("inbox"),
priority: smallint("priority"),
dueAt: timestamp("due_at", { withTimezone: true }),
tags: text("tags").array().notNull().default(sql`'{}'::text[]`),
projectId: uuid("project_id"),
checklist: jsonb("checklist").notNull().default(sql`'[]'::jsonb`),
energy: text("energy"),
context: text("context"),
recurrence: text("recurrence"),
ticketUrl: text("ticket_url"),
verificationSteps: text("verification_steps").array().notNull().default(sql`'{}'::text[]`),
aiEnriched: boolean("ai_enriched").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({
statusIdx: index("todos_status_idx").on(t.status),
dueAtIdx: index("todos_due_at_idx").on(t.dueAt),
tagsGin: index("todos_tags_gin").using("gin", t.tags),
}),
);
export type TodoRow = typeof todos.$inferSelect;
export type TodoInsert = typeof todos.$inferInsert;

15
packages/db/tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2022",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"verbatimModuleSyntax": false
},
"include": ["src"]
}

View File

@ -2,13 +2,16 @@
"name": "@ordinarthur-os/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./src/index.ts"
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@ -113,6 +113,177 @@ export const JobSearchCriteriaUpsert = z.object({
});
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
// ---------------------------------------------------------------------------
// Phase 2 — Todos
// ---------------------------------------------------------------------------
export const TodoStatus = z.enum(["inbox", "todo", "doing", "done", "archived"]);
export type TodoStatus = z.infer<typeof TodoStatus>;
export const TodoEnergy = z.enum(["low", "med", "high"]);
export type TodoEnergy = z.infer<typeof TodoEnergy>;
export const TodoChecklistItem = z.object({
text: z.string().min(1),
done: z.boolean().default(false),
});
export type TodoChecklistItem = z.infer<typeof TodoChecklistItem>;
export const TodoPriority = z.number().int().min(0).max(3);
export type TodoPriority = z.infer<typeof TodoPriority>;
export const Todo = z.object({
id: z.string().uuid(),
title: z.string(),
description: z.string().nullable(),
status: TodoStatus,
priority: TodoPriority.nullable(),
due_at: z.string().nullable(),
tags: z.array(z.string()),
project_id: z.string().uuid().nullable(),
checklist: z.array(TodoChecklistItem),
energy: TodoEnergy.nullable(),
context: z.string().nullable(),
recurrence: z.string().nullable(),
ticket_url: z.string().nullable(),
verification_steps: z.array(z.string()),
ai_enriched: z.boolean(),
created_at: z.string(),
completed_at: z.string().nullable(),
});
export type Todo = z.infer<typeof Todo>;
export const TodoCreateDto = z.object({
title: z.string().min(1),
description: z.string().optional().nullable(),
status: TodoStatus.default("inbox"),
priority: TodoPriority.optional().nullable(),
due_at: z.string().datetime().optional().nullable(),
tags: z.array(z.string()).default([]),
project_id: z.string().uuid().optional().nullable(),
checklist: z.array(TodoChecklistItem).default([]),
energy: TodoEnergy.optional().nullable(),
context: z.string().optional().nullable(),
recurrence: z.string().optional().nullable(),
ticket_url: z.string().url().optional().nullable(),
verification_steps: z.array(z.string()).default([]),
});
export type TodoCreateDto = z.infer<typeof TodoCreateDto>;
export const TodoPatchDto = TodoCreateDto.partial().extend({
ai_enriched: z.boolean().optional(),
completed_at: z.string().datetime().optional().nullable(),
});
export type TodoPatchDto = z.infer<typeof TodoPatchDto>;
export const TodoListQuery = z.object({
status: TodoStatus.optional(),
priority: z.coerce.number().int().min(0).max(3).optional(),
context: z.string().optional(),
tags: z.array(z.string()).optional(),
project_id: z.string().uuid().optional(),
archived: z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
.optional(),
});
export type TodoListQuery = z.infer<typeof TodoListQuery>;
export const TodoAiEnrichDraft = z.object({
description: z.string().optional(),
priority: TodoPriority.optional(),
tags: z.array(z.string()).optional(),
context: z.string().optional(),
energy: TodoEnergy.optional(),
verification_steps: z.array(z.string()).optional(),
});
export type TodoAiEnrichDraft = z.infer<typeof TodoAiEnrichDraft>;
export const TodoAiEnrichResponse = z.object({
todo_id: z.string().uuid(),
draft: TodoAiEnrichDraft,
});
export type TodoAiEnrichResponse = z.infer<typeof TodoAiEnrichResponse>;
export const TodoAiEnrichApplyRequest = z.object({
draft: TodoAiEnrichDraft,
});
export type TodoAiEnrichApplyRequest = z.infer<typeof TodoAiEnrichApplyRequest>;
// ---------------------------------------------------------------------------
// Phase 3 — Projects
// ---------------------------------------------------------------------------
export const ProjectStatus = z.enum(["active", "archived"]);
export type ProjectStatus = z.infer<typeof ProjectStatus>;
export const StepStatus = z.enum(["backlog", "todo", "doing", "review", "done"]);
export type StepStatus = z.infer<typeof StepStatus>;
export const Project = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable(),
status: ProjectStatus,
created_at: z.string(),
updated_at: z.string(),
});
export type Project = z.infer<typeof Project>;
export const ProjectCreateDto = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
status: ProjectStatus.default("active"),
});
export type ProjectCreateDto = z.infer<typeof ProjectCreateDto>;
export const ProjectPatchDto = ProjectCreateDto.partial();
export type ProjectPatchDto = z.infer<typeof ProjectPatchDto>;
export const ProjectStep = z.object({
id: z.string().uuid(),
project_id: z.string().uuid(),
title: z.string(),
status: StepStatus,
position: z.number(),
created_at: z.string(),
updated_at: z.string(),
});
export type ProjectStep = z.infer<typeof ProjectStep>;
export const ProjectStepCreateDto = z.object({
title: z.string().min(1),
status: StepStatus.default("backlog"),
position: z.number().optional(),
});
export type ProjectStepCreateDto = z.infer<typeof ProjectStepCreateDto>;
export const ProjectStepPatchDto = z.object({
title: z.string().min(1).optional(),
status: StepStatus.optional(),
position: z.number().optional(),
});
export type ProjectStepPatchDto = z.infer<typeof ProjectStepPatchDto>;
export const ProjectIdea = z.object({
id: z.string().uuid(),
project_id: z.string().uuid().nullable(),
content: z.string(),
priority: z.number().int().min(0).max(3).nullable(),
created_at: z.string(),
});
export type ProjectIdea = z.infer<typeof ProjectIdea>;
export const ProjectIdeaCreateDto = z.object({
content: z.string().min(1),
priority: z.number().int().min(0).max(3).optional().nullable(),
project_id: z.string().uuid().optional().nullable(),
});
export type ProjectIdeaCreateDto = z.infer<typeof ProjectIdeaCreateDto>;
export const ProjectIdeaPatchDto = ProjectIdeaCreateDto.partial();
export type ProjectIdeaPatchDto = z.infer<typeof ProjectIdeaPatchDto>;
// ---------------------------------------------------------------------------
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
// ---------------------------------------------------------------------------
@ -123,7 +294,7 @@ export const ProposedAction = z.discriminatedUnion("fn", [
args: z.object({
title: z.string(),
description: z.string().optional(),
due_at: z.string().datetime().optional(),
due_at: z.string().datetime({ offset: true }).optional(),
priority: z.number().int().min(0).max(3).optional(),
project_id: z.string().uuid().optional(),
tags: z.array(z.string()).optional(),
@ -148,8 +319,8 @@ export const ProposedAction = z.discriminatedUnion("fn", [
fn: z.literal("create_calendar_event"),
args: z.object({
title: z.string(),
starts_at: z.string().datetime(),
ends_at: z.string().datetime(),
starts_at: z.string().datetime({ offset: true }),
ends_at: z.string().datetime({ offset: true }),
location: z.string().optional(),
description: z.string().optional(),
}),
@ -160,5 +331,122 @@ export const ProposedAction = z.discriminatedUnion("fn", [
note: z.string().optional(),
}),
}),
z.object({
fn: z.literal("capture_idea"),
args: z.object({
content: z.string(),
priority: z.number().int().min(0).max(3).optional(),
project_id: z.string().uuid().optional(),
}),
}),
]);
export type ProposedAction = z.infer<typeof ProposedAction>;
/**
* Chaque action proposée est persistée dans `ai_actions` en status='proposed'.
* L'API renvoie un id par action pour que la PWA puisse les confirmer/annuler.
*/
export const ProposedActionWithId = z.object({
id: z.string().uuid(),
action: ProposedAction,
});
export type ProposedActionWithId = z.infer<typeof ProposedActionWithId>;
// POST /ai/command — texte brut (ex: Cmd-K)
export const AiCommandRequest = z.object({
text: z.string().min(1).max(4000),
});
export type AiCommandRequest = z.infer<typeof AiCommandRequest>;
export const AiCommandResponse = z.object({
actions: z.array(ProposedActionWithId),
});
export type AiCommandResponse = z.infer<typeof AiCommandResponse>;
// POST /ai/voice — réponse multipart (transcript + actions)
export const AiVoiceResponse = z.object({
transcript: z.string(),
actions: z.array(ProposedActionWithId),
});
export type AiVoiceResponse = z.infer<typeof AiVoiceResponse>;
// POST /ai/command/confirm — la PWA confirme/annule les actions proposées
export const AiConfirmRequest = z.object({
action_ids: z.array(z.string().uuid()).min(1),
});
export type AiConfirmRequest = z.infer<typeof AiConfirmRequest>;
export const AiActionResult = z.object({
id: z.string().uuid(),
status: z.enum(["confirmed", "failed"]),
result: z.unknown().optional(),
error: z.string().optional(),
});
export type AiActionResult = z.infer<typeof AiActionResult>;
export const AiConfirmResponse = z.object({
results: z.array(AiActionResult),
});
export type AiConfirmResponse = z.infer<typeof AiConfirmResponse>;
// ---------------------------------------------------------------------------
// Phase 4 — Agenda / Calendar events
// ---------------------------------------------------------------------------
export const CalendarEvent = z.object({
id: z.string().uuid(),
google_event_id: z.string().nullable(),
title: z.string(),
description: z.string().nullable(),
location: z.string().nullable(),
starts_at: z.string(),
ends_at: z.string(),
all_day: z.boolean(),
source: z.string(),
created_at: z.string(),
updated_at: z.string(),
});
export type CalendarEvent = z.infer<typeof CalendarEvent>;
export const CalendarEventCreateDto = z.object({
title: z.string().min(1),
description: z.string().nullable().optional(),
location: z.string().nullable().optional(),
starts_at: z.string().datetime({ offset: true }),
ends_at: z.string().datetime({ offset: true }),
all_day: z.boolean().optional(),
});
export type CalendarEventCreateDto = z.infer<typeof CalendarEventCreateDto>;
export const CalendarEventPatchDto = CalendarEventCreateDto.partial();
export type CalendarEventPatchDto = z.infer<typeof CalendarEventPatchDto>;
export const CalendarEventListQuery = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
export type CalendarEventListQuery = z.infer<typeof CalendarEventListQuery>;
export const GoogleSyncResponse = z.object({
inserted: z.number().int(),
updated: z.number().int(),
});
export type GoogleSyncResponse = z.infer<typeof GoogleSyncResponse>;
// ---------------------------------------------------------------------------
// Phase 7 — Daily check-in (médocs + note)
// ---------------------------------------------------------------------------
export const DailyCheckin = z.object({
day: z.string(), // YYYY-MM-DD
meds_taken: z.boolean(),
note: z.string().nullable(),
updated_at: z.string(),
});
export type DailyCheckin = z.infer<typeof DailyCheckin>;
export const DailyCheckinToggleRequest = z.object({
meds_taken: z.boolean().optional(),
note: z.string().nullable().optional(),
});
export type DailyCheckinToggleRequest = z.infer<typeof DailyCheckinToggleRequest>;

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src"]
}

805
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,8 @@
},
"dev": {
"cache": false,
"persistent": true
"persistent": true,
"dependsOn": ["^build"]
},
"lint": {},
"typecheck": {