Compare commits
10 Commits
eb430b59e6
...
be01895c92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be01895c92 | ||
|
|
6fdfbab996 | ||
|
|
7de7ef16b9 | ||
|
|
242abdba5d | ||
|
|
22e5ed1a15 | ||
|
|
d55a552d2e | ||
|
|
b71d5c8f47 | ||
|
|
32f3105bef | ||
|
|
f851da4677 | ||
|
|
9c93e74318 |
50
.claude/settings.local.json
Normal file
50
.claude/settings.local.json
Normal 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}')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
113
ARCHITECTURE.md
113
ARCHITECTURE.md
@ -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=
|
||||
|
||||
@ -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
24
PLAN.md
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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("*");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
100
apps/api/src/modules/agenda/agenda.controller.ts
Normal file
100
apps/api/src/modules/agenda/agenda.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/agenda/agenda.module.ts
Normal file
9
apps/api/src/modules/agenda/agenda.module.ts
Normal 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 {}
|
||||
373
apps/api/src/modules/agenda/agenda.service.ts
Normal file
373
apps/api/src/modules/agenda/agenda.service.ts
Normal 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");
|
||||
}
|
||||
64
apps/api/src/modules/ai/ai.controller.ts
Normal file
64
apps/api/src/modules/ai/ai.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/modules/ai/ai.module.ts
Normal file
13
apps/api/src/modules/ai/ai.module.ts
Normal 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 {}
|
||||
251
apps/api/src/modules/ai/ai.service.ts
Normal file
251
apps/api/src/modules/ai/ai.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
apps/api/src/modules/ai/groq.client.ts
Normal file
50
apps/api/src/modules/ai/groq.client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
235
apps/api/src/modules/ai/mistral.client.ts
Normal file
235
apps/api/src/modules/ai/mistral.client.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
21
apps/api/src/modules/health-tab/health-tab.controller.ts
Normal file
21
apps/api/src/modules/health-tab/health-tab.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/health-tab/health-tab.module.ts
Normal file
9
apps/api/src/modules/health-tab/health-tab.module.ts
Normal 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 {}
|
||||
80
apps/api/src/modules/health-tab/health-tab.service.ts
Normal file
80
apps/api/src/modules/health-tab/health-tab.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
136
apps/api/src/modules/projects/projects.controller.ts
Normal file
136
apps/api/src/modules/projects/projects.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/projects/projects.module.ts
Normal file
10
apps/api/src/modules/projects/projects.module.ts
Normal 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 {}
|
||||
217
apps/api/src/modules/projects/projects.service.ts
Normal file
217
apps/api/src/modules/projects/projects.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
60
apps/api/src/modules/todos/todos.controller.ts
Normal file
60
apps/api/src/modules/todos/todos.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/todos/todos.module.ts
Normal file
9
apps/api/src/modules/todos/todos.module.ts
Normal 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 {}
|
||||
209
apps/api/src/modules/todos/todos.service.ts
Normal file
209
apps/api/src/modules/todos/todos.service.ts
Normal 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"]);
|
||||
@ -8,6 +8,10 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"verbatimModuleSyntax": false,
|
||||
"isolatedModules": false
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
58
apps/pwa/src/components/agenda/EventCard.tsx
Normal file
58
apps/pwa/src/components/agenda/EventCard.tsx
Normal 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",
|
||||
});
|
||||
}
|
||||
122
apps/pwa/src/components/agenda/NewEventForm.tsx
Normal file
122
apps/pwa/src/components/agenda/NewEventForm.tsx
Normal 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);
|
||||
}
|
||||
96
apps/pwa/src/components/agenda/WeekStrip.tsx
Normal file
96
apps/pwa/src/components/agenda/WeekStrip.tsx
Normal 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)}`;
|
||||
}
|
||||
167
apps/pwa/src/components/ai/ActionEditor.tsx
Normal file
167
apps/pwa/src/components/ai/ActionEditor.tsx
Normal 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
|
||||
}
|
||||
206
apps/pwa/src/components/ai/MagicButton.tsx
Normal file
206
apps/pwa/src/components/ai/MagicButton.tsx
Normal 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;
|
||||
}
|
||||
117
apps/pwa/src/components/ai/VoiceButton.tsx
Normal file
117
apps/pwa/src/components/ai/VoiceButton.tsx
Normal 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;
|
||||
}
|
||||
303
apps/pwa/src/components/ai/VoiceConfirmModal.tsx
Normal file
303
apps/pwa/src/components/ai/VoiceConfirmModal.tsx
Normal 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; }
|
||||
}
|
||||
39
apps/pwa/src/components/health/MedsSection.tsx
Normal file
39
apps/pwa/src/components/health/MedsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
apps/pwa/src/components/health/MedsSlider.tsx
Normal file
108
apps/pwa/src/components/health/MedsSlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
apps/pwa/src/components/projects/IdeaList.tsx
Normal file
134
apps/pwa/src/components/projects/IdeaList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
apps/pwa/src/components/projects/KanbanBoard.tsx
Normal file
338
apps/pwa/src/components/projects/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/pwa/src/components/shell/BottomNav.tsx
Normal file
31
apps/pwa/src/components/shell/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
228
apps/pwa/src/routes/agenda.tsx
Normal file
228
apps/pwa/src/routes/agenda.tsx
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
183
apps/pwa/src/routes/projects.$projectId.tsx
Normal file
183
apps/pwa/src/routes/projects.$projectId.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
apps/pwa/src/routes/projects.index.tsx
Normal file
201
apps/pwa/src/routes/projects.index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
apps/pwa/src/routes/projects.tsx
Normal file
5
apps/pwa/src/routes/projects.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/projects")({
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
221
apps/pwa/src/routes/todos.tsx
Normal file
221
apps/pwa/src/routes/todos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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é.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 } } }
|
||||
37
deploy/k8s/migrate.job.yaml
Normal file
37
deploy/k8s/migrate.job.yaml
Normal 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
66
deploy/k8s/postgres.yaml
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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
35
docker-compose.yml
Normal 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
|
||||
@ -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.
|
||||
|
||||
22
packages/db/drizzle.config.ts
Normal file
22
packages/db/drizzle.config.ts
Normal 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;
|
||||
10
packages/db/migrations/0000_init.sql
Normal file
10
packages/db/migrations/0000_init.sql
Normal 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.';
|
||||
51
packages/db/migrations/0001_jobs.sql
Normal file
51
packages/db/migrations/0001_jobs.sql
Normal 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");
|
||||
@ -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.)
|
||||
@ -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');
|
||||
27
packages/db/migrations/0002_todos.sql
Normal file
27
packages/db/migrations/0002_todos.sql
Normal 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);
|
||||
20
packages/db/migrations/0003_ai_actions.sql
Normal file
20
packages/db/migrations/0003_ai_actions.sql
Normal 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);
|
||||
10
packages/db/migrations/0004_daily_checkins.sql
Normal file
10
packages/db/migrations/0004_daily_checkins.sql
Normal 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()
|
||||
);
|
||||
29
packages/db/migrations/0005_agenda.sql
Normal file
29
packages/db/migrations/0005_agenda.sql
Normal 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
|
||||
);
|
||||
31
packages/db/migrations/0006_projects.sql
Normal file
31
packages/db/migrations/0006_projects.sql
Normal 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);
|
||||
3
packages/db/migrations/0007_ideas_nullable_project.sql
Normal file
3
packages/db/migrations/0007_ideas_nullable_project.sql
Normal 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;
|
||||
62
packages/db/migrations/meta/_journal.json
Normal file
62
packages/db/migrations/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
31
packages/db/src/client.ts
Normal 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
3
packages/db/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * as schema from "./schema/index";
|
||||
export { appSchema } from "./schema/index";
|
||||
export { createDb, type Db, type DbHandle } from "./client";
|
||||
30
packages/db/src/migrate.ts
Normal file
30
packages/db/src/migrate.ts
Normal 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);
|
||||
});
|
||||
8
packages/db/src/schema/_schema.ts
Normal file
8
packages/db/src/schema/_schema.ts
Normal 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");
|
||||
26
packages/db/src/schema/ai_actions.ts
Normal file
26
packages/db/src/schema/ai_actions.ts
Normal 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;
|
||||
35
packages/db/src/schema/calendar_events.ts
Normal file
35
packages/db/src/schema/calendar_events.ts
Normal 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;
|
||||
12
packages/db/src/schema/daily_checkins.ts
Normal file
12
packages/db/src/schema/daily_checkins.ts
Normal 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;
|
||||
7
packages/db/src/schema/index.ts
Normal file
7
packages/db/src/schema/index.ts
Normal 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";
|
||||
67
packages/db/src/schema/jobs.ts
Normal file
67
packages/db/src/schema/jobs.ts
Normal 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;
|
||||
47
packages/db/src/schema/projects.ts
Normal file
47
packages/db/src/schema/projects.ts
Normal 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;
|
||||
34
packages/db/src/schema/todos.ts
Normal file
34
packages/db/src/schema/todos.ts
Normal 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
15
packages/db/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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>;
|
||||
|
||||
14
packages/shared/tsconfig.build.json
Normal file
14
packages/shared/tsconfig.build.json
Normal 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
805
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,8 @@
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
"persistent": true,
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"lint": {},
|
||||
"typecheck": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user