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.
|
name: Build & Deploy
|
||||||
# - Build images api + pwa
|
|
||||||
# - Push vers Gitea Container Registry
|
|
||||||
# - kubectl set image (ou apply via kustomize plus tard)
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v3
|
|
||||||
with: { version: 9 }
|
- name: Set image tags
|
||||||
- uses: actions/setup-node@v4
|
run: |
|
||||||
with: { node-version: 20, cache: pnpm }
|
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||||
- run: pnpm install --frozen-lockfile
|
echo "API_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:$SHA" >> $GITHUB_ENV
|
||||||
- run: pnpm -r build
|
echo "PWA_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:$SHA" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Login Gitea Container Registry
|
- 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: |
|
run: |
|
||||||
API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||||
PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
docker login git.arthurbarre.fr -u ordinarthur --password-stdin
|
||||||
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
|
||||||
docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" .
|
|
||||||
docker push "$API_TAG"
|
|
||||||
docker push "$PWA_TAG"
|
|
||||||
echo "API_TAG=$API_TAG" >> $GITHUB_ENV
|
|
||||||
echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Deploy on k3s
|
- 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:
|
env:
|
||||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
||||||
run: |
|
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/api api=$API_TAG
|
||||||
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
|
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
|
||||||
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
|
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Avant toute action, lire dans cet ordre :
|
|||||||
- **Single-user.** Bearer token statique, pas de multi-tenant.
|
- **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.
|
- **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).
|
- **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
|
## Phases
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) r
|
|||||||
|
|
||||||
- Monorepo pnpm + Turborepo
|
- Monorepo pnpm + Turborepo
|
||||||
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
|
- `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/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`
|
- 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 │
|
│ Postgres │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
|
||||||
│ self-host │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
|
│ (k3s STS) │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
|
||||||
│ schema │ │ function- │ │ │ │ │ │ │
|
│ schema │ │ function- │ │ │ │ │ │ │
|
||||||
│ ordinarthur│ │ calling │ │ │ │ │ │ │
|
│ ordinarthur│ │ calling │ │ │ │ │ │ │
|
||||||
│ _os │ │ │ │ │ │ │ │ │
|
│ _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
|
## 2. Monorepo
|
||||||
|
|
||||||
@ -33,7 +33,12 @@ ordinarthur-os/
|
|||||||
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
|
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
|
||||||
│ │ └── src/
|
│ │ └── src/
|
||||||
│ │ ├── routes/ # / /jobs /todos /projects /agenda /health /settings/*
|
│ │ ├── 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
|
│ │ ├── design/ # Tokens + primitives éditoriales
|
||||||
│ │ ├── api/ # Client HTTP typé, zod parse
|
│ │ ├── api/ # Client HTTP typé, zod parse
|
||||||
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
|
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
|
||||||
@ -52,20 +57,28 @@ ordinarthur-os/
|
|||||||
│ │ ├── ai/ # command, voice, function dispatcher
|
│ │ ├── ai/ # command, voice, function dispatcher
|
||||||
│ │ ├── telegram/
|
│ │ ├── telegram/
|
||||||
│ │ └── sync/ # replay client_mutations, dedup
|
│ │ └── sync/ # replay client_mutations, dedup
|
||||||
│ ├── db/ # supabase client factory
|
│ ├── db/ # Drizzle client factory (@ordinarthur-os/db)
|
||||||
│ ├── config/ # env schema (zod)
|
│ ├── config/ # env schema (zod)
|
||||||
│ └── main.ts
|
│ └── main.ts
|
||||||
│
|
│
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── shared/ # Types + zod DTOs partagés pwa/api
|
│ ├── shared/ # Types + zod DTOs partagés pwa/api
|
||||||
│ └── db/
|
│ └── db/ # Drizzle ORM : schema TS + migrations SQL
|
||||||
│ └── migrations/ # 0001_schema.sql, 0002_jobs.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/
|
├── deploy/
|
||||||
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
|
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
|
||||||
|
│ ├── namespace.yaml
|
||||||
|
│ ├── postgres.yaml # StatefulSet Postgres 16 + PVC
|
||||||
│ ├── api.deployment.yaml
|
│ ├── api.deployment.yaml
|
||||||
│ ├── pwa.deployment.yaml
|
│ ├── pwa.deployment.yaml
|
||||||
│ ├── ingress.yaml
|
│ ├── ingress.yaml
|
||||||
|
│ ├── migrate.job.yaml # Job one-shot drizzle migrate
|
||||||
│ ├── secrets.template.yaml
|
│ ├── secrets.template.yaml
|
||||||
│ └── backup.cronjob.yaml
|
│ └── backup.cronjob.yaml
|
||||||
│
|
│
|
||||||
@ -115,6 +128,7 @@ theme: {
|
|||||||
|
|
||||||
### Règles visuelles
|
### 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
|
- Bordures partout : sections, grid cells, nav
|
||||||
- Pas d'ombre portée (shadow-none)
|
- Pas d'ombre portée (shadow-none)
|
||||||
- Imagery grayscale par défaut
|
- Imagery grayscale par défaut
|
||||||
@ -122,7 +136,53 @@ theme: {
|
|||||||
- Motion minimal (fade ≤ 200ms), pas d'animation flashy
|
- Motion minimal (fade ≤ 200ms), pas d'animation flashy
|
||||||
- `accent-pulse` : petit dot orange qui pulse doucement pour marquer "live/disponible" (voir header portfolio)
|
- `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
|
```sql
|
||||||
create schema if not exists ordinarthur_os;
|
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).
|
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.
|
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)
|
/ 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)
|
- 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)
|
- 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
|
1. `MediaRecorder` dans le navigateur → blob WebM/Opus
|
||||||
2. `POST /ai/voice` (multipart) vers NestJS
|
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**
|
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. Sur Confirmer → `POST /ai/command/confirm` → Nest exécute + update `ai_actions.status='confirmed'`
|
||||||
|
|
||||||
## 8. Déploiement k3s
|
## 9. Déploiement k3s
|
||||||
|
|
||||||
### Ingress
|
### Ingress
|
||||||
|
|
||||||
@ -427,10 +487,9 @@ spec:
|
|||||||
|
|
||||||
### Secrets k8s attendus
|
### Secrets k8s attendus
|
||||||
|
|
||||||
`ordinarthur-os-secrets` :
|
`ordinarthur-os-secrets` (consommé par l'API) :
|
||||||
- `API_BEARER_TOKEN`
|
- `API_BEARER_TOKEN`
|
||||||
- `SUPABASE_URL` (`https://supabase.arthurbarre.fr`)
|
- `DATABASE_URL` (`postgres://<user>:<pwd>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os`)
|
||||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
|
||||||
- `MISTRAL_API_KEY`
|
- `MISTRAL_API_KEY`
|
||||||
- `GROQ_API_KEY`
|
- `GROQ_API_KEY`
|
||||||
- `GOOGLE_OAUTH_CLIENT_ID`
|
- `GOOGLE_OAUTH_CLIENT_ID`
|
||||||
@ -440,6 +499,13 @@ spec:
|
|||||||
- `TELEGRAM_WEBHOOK_SECRET`
|
- `TELEGRAM_WEBHOOK_SECRET`
|
||||||
- `ICAL_FEED_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
|
### CronJob backup
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@ -462,10 +528,11 @@ spec:
|
|||||||
args:
|
args:
|
||||||
- |
|
- |
|
||||||
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
|
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 "$RCLONE_REMOTE/$(date +%F).gz"
|
||||||
rclone copy /tmp/dump.gz remote:ordinarthur-os-backups/$(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)
|
### Pipeline Gitea (à aligner avec le skill `/deploy` d'Arthur)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@ -494,16 +561,16 @@ jobs:
|
|||||||
kubectl set image deploy/pwa pwa=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
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
|
- 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)
|
- 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`
|
- Webhook Telegram : vérification de `X-Telegram-Bot-Api-Secret-Token`
|
||||||
- Feed iCal : path contient un secret rotable (`ICAL_FEED_SECRET`)
|
- 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
|
- 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à
|
- Logs Nest en JSON → si Arthur a déjà Loki/Grafana, les envoyer là
|
||||||
- Health probes Liveness / Readiness sur `/health`
|
- Health probes Liveness / Readiness sur `/health`
|
||||||
@ -519,9 +586,7 @@ jobs:
|
|||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
API_BEARER_TOKEN=
|
API_BEARER_TOKEN=
|
||||||
SUPABASE_URL=https://supabase.arthurbarre.fr
|
DATABASE_URL=postgres://ordinarthur:changeme@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
|
||||||
SUPABASE_SCHEMA=ordinarthur_os
|
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
MISTRAL_MODEL=mistral-small-latest
|
||||||
GROQ_API_KEY=
|
GROQ_API_KEY=
|
||||||
|
|||||||
@ -13,7 +13,7 @@ Avant toute action, lire dans cet ordre :
|
|||||||
- **Single-user.** Bearer token statique, pas de multi-tenant.
|
- **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.
|
- **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).
|
- **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
|
## Phases
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) r
|
|||||||
|
|
||||||
- Monorepo pnpm + Turborepo
|
- Monorepo pnpm + Turborepo
|
||||||
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
|
- `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/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`
|
- 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
|
- Dashboard clair de ce qu'il fait / veut faire
|
||||||
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
||||||
- Pas de "weekly review" automatique, pas de nudges
|
- 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
|
## Principes directeurs
|
||||||
|
|
||||||
1. **Self-hosted, open-source**. Pas de Vercel, pas de Next.js. Tout tourne sur le k3s d'Arthur.
|
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.
|
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.
|
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).
|
5. **Design éditorial / Swiss-brutalist** — mirror du portfolio arthurbarre.fr (cream, ink, orange, borders, mono labels).
|
||||||
|
|
||||||
## Stack verrouillée
|
## Stack verrouillée
|
||||||
@ -25,8 +25,8 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Monorepo | pnpm workspaces + Turborepo |
|
| Monorepo | pnpm workspaces + Turborepo |
|
||||||
| Frontend | Vite + React 18 + TanStack Router + TanStack Query + Tailwind + shadcn/ui |
|
| Frontend | Vite + React 18 + TanStack Router + TanStack Query + Tailwind + shadcn/ui |
|
||||||
| Backend | NestJS + `@supabase/supabase-js` (pas d'ORM) |
|
| Backend | NestJS + Drizzle ORM (driver `postgres`) |
|
||||||
| DB | Postgres via Supabase self-hosted, schéma dédié `ordinarthur_os` |
|
| DB | Postgres 16 standalone (k3s StatefulSet + PVC), schéma dédié `ordinarthur_os` |
|
||||||
| Auth | Bearer token statique (single-user), middleware Nest |
|
| Auth | Bearer token statique (single-user), middleware Nest |
|
||||||
| IA LLM | Mistral `mistral-small-latest` (low-cost) via API |
|
| IA LLM | Mistral `mistral-small-latest` (low-cost) via API |
|
||||||
| STT | Groq `whisper-large-v3-turbo` |
|
| 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)
|
### Phase 0 — Scaffold (prio immédiate)
|
||||||
- Monorepo `pnpm-workspace.yaml`, `turbo.json`
|
- Monorepo `pnpm-workspace.yaml`, `turbo.json`
|
||||||
- `apps/pwa` : Vite + React + Tailwind + shadcn + manifest PWA + service worker placeholder + routing TanStack
|
- `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/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)
|
- `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
|
- Design system : composants primitifs (`<Label>`, `<SectionHeader>`, `<GridFrame>`, `<DataChip>`, `<MetaRow>`) qui reproduisent le style arthurbarre.fr
|
||||||
- Routes `GET /health` et `POST /auth/verify`
|
- 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`)
|
- 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
|
- 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
|
### Phase 2 — Todos (capture rapide)
|
||||||
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet)
|
- Migration : table `todos`
|
||||||
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation)
|
- API : CRUD complet (schéma riche pour que la Phase 5 puisse tout remplir via function calling)
|
||||||
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline
|
- 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)**.
|
||||||
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations`
|
- 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
|
### Phase 3 — Projets + Kanban
|
||||||
- Migrations : `projects`, `project_steps`, `project_ideas`
|
- 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 :
|
4. Avant de scaffolder, récupérer de l'utilisateur :
|
||||||
- Le dossier `/Users/arthurbarre/dev/perso/proxmox` (conf k3s) pour aligner les manifests
|
- 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
|
- 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)
|
- 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.
|
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
|
# 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.
|
**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`)
|
# Single-user bearer (génère via `openssl rand -hex 32`)
|
||||||
API_BEARER_TOKEN=
|
API_BEARER_TOKEN=
|
||||||
|
|
||||||
# Supabase self-hosted
|
# Postgres standalone (k3s ou local docker). Le schéma `ordinarthur_os`
|
||||||
SUPABASE_URL=https://supabase.arthurbarre.fr
|
# est géré par Drizzle, pas besoin de `search_path` dans l'URL.
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
DATABASE_URL=postgres://ordinarthur:changeme@localhost:5432/ordinarthur_os
|
||||||
SUPABASE_SCHEMA=ordinarthur_os
|
|
||||||
|
|
||||||
# Phase 5+
|
# Phase 5+
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
|
|||||||
@ -1,24 +1,42 @@
|
|||||||
# Multi-stage build : build avec pnpm puis runtime minimal
|
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS builder
|
||||||
RUN corepack enable
|
WORKDIR /workspace
|
||||||
WORKDIR /repo
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
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 apps/api/package.json apps/api/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
RUN pnpm install --frozen-lockfile || pnpm install
|
|
||||||
|
|
||||||
FROM deps AS build
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Sources complètes
|
||||||
COPY . .
|
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
|
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
|
FROM node:20-alpine AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN corepack enable
|
|
||||||
COPY --from=build /repo/apps/api/dist ./dist
|
# Deps de prod isolées (inclut @ordinarthur-os/shared+db compilés)
|
||||||
COPY --from=build /repo/apps/api/package.json ./package.json
|
COPY --from=builder /deploy/api/node_modules ./node_modules
|
||||||
COPY --from=build /repo/node_modules ./node_modules
|
COPY --from=builder /deploy/api/package.json ./
|
||||||
COPY --from=build /repo/packages ./packages
|
|
||||||
|
# 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
|
EXPOSE 3000
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@ -13,10 +13,13 @@
|
|||||||
"@nestjs/common": "^10.4.4",
|
"@nestjs/common": "^10.4.4",
|
||||||
"@nestjs/core": "^10.4.4",
|
"@nestjs/core": "^10.4.4",
|
||||||
"@nestjs/platform-express": "^10.4.4",
|
"@nestjs/platform-express": "^10.4.4",
|
||||||
|
"@ordinarthur-os/db": "workspace:*",
|
||||||
"@ordinarthur-os/shared": "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",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -5,9 +5,25 @@ import { HealthModule } from "./modules/health/health.module";
|
|||||||
import { AuthModule } from "./modules/auth/auth.module";
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||||
import { JobsModule } from "./modules/jobs/jobs.module";
|
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({
|
@Module({
|
||||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
DbModule,
|
||||||
|
HealthModule,
|
||||||
|
AuthModule,
|
||||||
|
JobsModule,
|
||||||
|
TodosModule,
|
||||||
|
AiModule,
|
||||||
|
HealthTabModule,
|
||||||
|
AgendaModule,
|
||||||
|
ProjectsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
@ -15,9 +31,8 @@ export class AppModule implements NestModule {
|
|||||||
.apply(BearerMiddleware)
|
.apply(BearerMiddleware)
|
||||||
.exclude(
|
.exclude(
|
||||||
{ path: "health", method: RequestMethod.GET },
|
{ path: "health", method: RequestMethod.GET },
|
||||||
// Endpoints publics (signés autrement) ajoutés en Phase 4/6 :
|
{ path: "agenda/ical/:secret", method: RequestMethod.GET },
|
||||||
// { path: "telegram/webhook", method: RequestMethod.POST },
|
{ path: "agenda/google/oauth/callback", method: RequestMethod.GET },
|
||||||
// { path: "agenda/ical/:secret.ics", method: RequestMethod.GET },
|
|
||||||
)
|
)
|
||||||
.forRoutes("*");
|
.forRoutes("*");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { z } from "zod";
|
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({
|
const EnvSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
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"),
|
API_BEARER_TOKEN: z.string().min(16, "API_BEARER_TOKEN must be at least 16 chars"),
|
||||||
|
|
||||||
SUPABASE_URL: z.string().url(),
|
// Postgres standalone (k3s) — pas de Supabase.
|
||||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
// Le driver `postgres` impose d'inclure le search_path via
|
||||||
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
|
// `?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_API_KEY: z.string().optional(),
|
||||||
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
||||||
GROQ_API_KEY: z.string().optional(),
|
GROQ_API_KEY: z.string().optional(),
|
||||||
GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"),
|
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_ID: z.string().optional(),
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
|
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
|
||||||
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(),
|
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(),
|
||||||
@ -32,53 +44,12 @@ let cached: AppConfig | null = null;
|
|||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
loadEnvFile();
|
|
||||||
|
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
||||||
throw new Error("Invalid environment configuration");
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
|
|
||||||
cached = parsed.data;
|
cached = parsed.data;
|
||||||
return cached;
|
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 { Global, Inject, Module, type OnApplicationShutdown } from "@nestjs/common";
|
||||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
import { createDb, type Db, type DbHandle } from "@ordinarthur-os/db";
|
||||||
import { APP_CONFIG } from "../config/config.module";
|
import { APP_CONFIG } from "../config/config.module";
|
||||||
import type { AppConfig } from "../config/env";
|
import type { AppConfig } from "../config/env";
|
||||||
|
|
||||||
export const SUPABASE = Symbol("SUPABASE");
|
export const DB = Symbol("DB");
|
||||||
export type Supabase = SupabaseClient<any, any, any, any, any>;
|
export const DB_HANDLE = Symbol("DB_HANDLE");
|
||||||
|
export type { Db };
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: SUPABASE,
|
provide: DB_HANDLE,
|
||||||
inject: [APP_CONFIG],
|
inject: [APP_CONFIG],
|
||||||
useFactory: (config: AppConfig): Supabase =>
|
useFactory: (config: AppConfig): DbHandle => createDb(config.DATABASE_URL),
|
||||||
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
},
|
||||||
auth: { persistSession: false, autoRefreshToken: false },
|
{
|
||||||
db: { schema: config.SUPABASE_SCHEMA },
|
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) {}`
|
async onApplicationShutdown() {
|
||||||
export const InjectSupabase = () => Inject(SUPABASE);
|
await this.handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `constructor(@InjectDb() private db: Db) {}` */
|
||||||
|
export const InjectDb = () => Inject(DB);
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
import "tsconfig-paths/register";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { raw } from "express";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { loadConfig } from "./config/env";
|
import { loadConfig } from "./config/env";
|
||||||
|
|
||||||
@ -9,6 +11,12 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ["log", "warn", "error"],
|
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 });
|
app.enableCors({ origin: true, credentials: false });
|
||||||
await app.listen(config.PORT);
|
await app.listen(config.PORT);
|
||||||
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");
|
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 { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { APP_CONFIG } from "../../config/config.module";
|
import { APP_CONFIG } from "@/config/config.module";
|
||||||
import type { AppConfig } from "../../config/env";
|
import type { AppConfig } from "@/config/env";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BearerMiddleware implements NestMiddleware {
|
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,
|
JobPatchDto,
|
||||||
JobSearchCriteriaUpsert,
|
JobSearchCriteriaUpsert,
|
||||||
} from "@ordinarthur-os/shared";
|
} from "@ordinarthur-os/shared";
|
||||||
import { ZodPipe } from "../../lib/zod-pipe";
|
import { ZodPipe } from "@/lib/zod-pipe";
|
||||||
import { JobsService } from "./jobs.service";
|
import { JobsService } from "./jobs.service";
|
||||||
|
|
||||||
@Controller("jobs")
|
@Controller("jobs")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { schema } from "@ordinarthur-os/db";
|
||||||
import {
|
import {
|
||||||
Job,
|
Job,
|
||||||
JobIngestDto,
|
JobIngestDto,
|
||||||
@ -8,61 +9,60 @@ import {
|
|||||||
JobSearchCriteria,
|
JobSearchCriteria,
|
||||||
JobSearchCriteriaUpsert,
|
JobSearchCriteriaUpsert,
|
||||||
} from "@ordinarthur-os/shared";
|
} 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;
|
const RETENTION_DAYS = 30;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobsService {
|
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 inserted = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
|
||||||
// On traite job par job pour distinguer insert vs update.
|
// Upsert sur `source_url` (clé de dedup). On insère et, en cas de conflit,
|
||||||
// Volume attendu : ~quelques dizaines/jour, c'est OK.
|
// on met à jour les champs mutables + bump `last_seen_at`. `xmax` permet
|
||||||
for (const j of jobs) {
|
// de savoir si la ligne résulte d'un INSERT (xmax=0) ou d'un UPDATE (xmax≠0).
|
||||||
const { data: existing } = await this.db
|
for (const j of input) {
|
||||||
.from("jobs")
|
const rows = await this.db
|
||||||
.select("id")
|
.insert(jobs)
|
||||||
.eq("source_url", j.source_url)
|
.values({
|
||||||
.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({
|
|
||||||
source: j.source,
|
source: j.source,
|
||||||
source_url: j.source_url,
|
sourceUrl: j.source_url,
|
||||||
title: j.title,
|
title: j.title,
|
||||||
company: j.company ?? null,
|
company: j.company ?? null,
|
||||||
description: j.description ?? null,
|
description: j.description ?? null,
|
||||||
location: j.location ?? null,
|
location: j.location ?? null,
|
||||||
remote_type: j.remote_type ?? null,
|
remoteType: j.remote_type ?? null,
|
||||||
salary_min: j.salary_min ?? null,
|
salaryMin: j.salary_min ?? null,
|
||||||
salary_max: j.salary_max ?? null,
|
salaryMax: j.salary_max ?? null,
|
||||||
stack: j.stack ?? [],
|
stack: j.stack ?? [],
|
||||||
apply_url: j.apply_url ?? null,
|
applyUrl: j.apply_url ?? null,
|
||||||
});
|
})
|
||||||
if (error) throw error;
|
.onConflictDoUpdate({
|
||||||
inserted++;
|
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();
|
const archived = await this.archiveStale();
|
||||||
@ -71,79 +71,147 @@ export class JobsService {
|
|||||||
|
|
||||||
/** Soft-delete des jobs non revus depuis RETENTION_DAYS jours. */
|
/** Soft-delete des jobs non revus depuis RETENTION_DAYS jours. */
|
||||||
private async archiveStale(): Promise<number> {
|
private async archiveStale(): Promise<number> {
|
||||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000).toISOString();
|
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000);
|
||||||
const { data, error } = await this.db
|
const rows = await this.db
|
||||||
.from("jobs")
|
.update(jobs)
|
||||||
.update({ archived: true })
|
.set({ archived: true })
|
||||||
.lt("last_seen_at", cutoff)
|
.where(and(lt(jobs.lastSeenAt, cutoff), eq(jobs.archived, false)))
|
||||||
.eq("archived", false)
|
.returning({ id: jobs.id });
|
||||||
.select("id");
|
return rows.length;
|
||||||
if (error) throw error;
|
|
||||||
return data?.length ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(q: JobListQuery): Promise<Job[]> {
|
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);
|
if (q.remote_type) filters.push(eq(jobs.remoteType, q.remote_type));
|
||||||
else query = query.eq("archived", false); // défaut : non archivés
|
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);
|
const rows = await this.db
|
||||||
if (q.starred !== undefined) query = query.eq("starred", q.starred);
|
.select()
|
||||||
if (q.since) query = query.gte("last_seen_at", q.since);
|
.from(jobs)
|
||||||
if (q.stack?.length) query = query.overlaps("stack", q.stack);
|
.where(and(...filters))
|
||||||
|
.orderBy(desc(jobs.lastSeenAt))
|
||||||
|
.limit(500);
|
||||||
|
|
||||||
const { data, error } = await query.limit(500);
|
return rows.map(toJobDto);
|
||||||
if (error) throw error;
|
|
||||||
return (data ?? []) as Job[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch(id: string, patch: JobPatchDto): Promise<Job> {
|
async patch(id: string, patch: JobPatchDto): Promise<Job> {
|
||||||
const { data, error } = await this.db
|
const rows = await this.db
|
||||||
.from("jobs")
|
.update(jobs)
|
||||||
.update(patch)
|
.set({
|
||||||
.eq("id", id)
|
...(patch.starred !== undefined && { starred: patch.starred }),
|
||||||
.select("*")
|
...(patch.archived !== undefined && { archived: patch.archived }),
|
||||||
.maybeSingle();
|
...(patch.applied_at !== undefined && {
|
||||||
if (error) throw error;
|
appliedAt: patch.applied_at === null ? null : new Date(patch.applied_at),
|
||||||
if (!data) throw new NotFoundException(`Job ${id} not found`);
|
}),
|
||||||
return data as Job;
|
...(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 ----------------------------------------------------------
|
// ---- criteria ----------------------------------------------------------
|
||||||
|
|
||||||
async listCriteria(activeOnly = false): Promise<JobSearchCriteria[]> {
|
async listCriteria(activeOnly = false): Promise<JobSearchCriteria[]> {
|
||||||
let q = this.db.from("job_search_criteria").select("*").order("created_at", { ascending: true });
|
const rows = await (activeOnly
|
||||||
if (activeOnly) q = q.eq("active", true);
|
? this.db
|
||||||
const { data, error } = await q;
|
.select()
|
||||||
if (error) throw error;
|
.from(jobSearchCriteria)
|
||||||
return (data ?? []) as 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> {
|
async createCriteria(input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
|
||||||
const { data, error } = await this.db
|
const rows = await this.db
|
||||||
.from("job_search_criteria")
|
.insert(jobSearchCriteria)
|
||||||
.insert(input)
|
.values({
|
||||||
.select("*")
|
name: input.name ?? null,
|
||||||
.single();
|
titles: input.titles,
|
||||||
if (error) throw error;
|
locations: input.locations,
|
||||||
return data as JobSearchCriteria;
|
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> {
|
async updateCriteria(id: string, input: JobSearchCriteriaUpsert): Promise<JobSearchCriteria> {
|
||||||
const { data, error } = await this.db
|
const rows = await this.db
|
||||||
.from("job_search_criteria")
|
.update(jobSearchCriteria)
|
||||||
.update({ ...input, updated_at: new Date().toISOString() })
|
.set({
|
||||||
.eq("id", id)
|
name: input.name ?? null,
|
||||||
.select("*")
|
titles: input.titles,
|
||||||
.maybeSingle();
|
locations: input.locations,
|
||||||
if (error) throw error;
|
stack: input.stack,
|
||||||
if (!data) throw new NotFoundException(`Criteria ${id} not found`);
|
remoteTypes: input.remote_types,
|
||||||
return data as JobSearchCriteria;
|
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> {
|
async deleteCriteria(id: string): Promise<void> {
|
||||||
const { error } = await this.db.from("job_search_criteria").delete().eq("id", id);
|
await this.db.delete(jobSearchCriteria).where(eq(jobSearchCriteria.id, id));
|
||||||
if (error) throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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,
|
"emitDecoratorMetadata": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
"verbatimModuleSyntax": false,
|
"verbatimModuleSyntax": false,
|
||||||
"isolatedModules": false
|
"isolatedModules": false
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,15 +1,28 @@
|
|||||||
FROM node:20-alpine AS build
|
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||||
RUN corepack enable
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /repo
|
WORKDIR /workspace
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
|
||||||
|
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 apps/pwa/package.json apps/pwa/
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
COPY packages/db/package.json packages/db/
|
COPY packages/db/package.json packages/db/
|
||||||
RUN pnpm install --frozen-lockfile || pnpm install
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
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
|
RUN pnpm --filter @ordinarthur-os/pwa build
|
||||||
|
|
||||||
|
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
|
||||||
FROM nginx:1.27-alpine AS 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
|
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@ordinarthur-os/shared": "workspace:*",
|
"@ordinarthur-os/shared": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tanstack/react-router": "^1.58.7",
|
"@tanstack/react-router": "^1.58.7",
|
||||||
|
|||||||
@ -47,3 +47,27 @@ export async function api<T = unknown>(
|
|||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<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
|
<Tag
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-sans font-light tracking-tightest text-ink",
|
"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",
|
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
|
||||||
className,
|
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.
|
// 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 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 JobsRouteImport } from './routes/jobs'
|
||||||
|
import { Route as AgendaRouteImport } from './routes/agenda'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
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 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({
|
const JobsRoute = JobsRouteImport.update({
|
||||||
id: '/jobs',
|
id: '/jobs',
|
||||||
path: '/jobs',
|
path: '/jobs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgendaRoute = AgendaRouteImport.update({
|
||||||
|
id: '/agenda',
|
||||||
|
path: '/agenda',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectsIndexRoute = ProjectsIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => ProjectsRoute,
|
||||||
|
} as any)
|
||||||
const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
||||||
id: '/settings/jobs',
|
id: '/settings/jobs',
|
||||||
path: '/settings/jobs',
|
path: '/settings/jobs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({
|
||||||
|
id: '/$projectId',
|
||||||
|
path: '/$projectId',
|
||||||
|
getParentRoute: () => ProjectsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/projects': typeof ProjectsRouteWithChildren
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects/': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/agenda': typeof AgendaRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/projects': typeof ProjectsRouteWithChildren
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
|
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
|
'/projects/': typeof ProjectsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/jobs' | '/settings/jobs'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/projects'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/jobs' | '/settings/jobs'
|
to:
|
||||||
id: '__root__' | '/' | '/jobs' | '/settings/jobs'
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/agenda'
|
||||||
|
| '/jobs'
|
||||||
|
| '/projects'
|
||||||
|
| '/todos'
|
||||||
|
| '/projects/$projectId'
|
||||||
|
| '/settings/jobs'
|
||||||
|
| '/projects/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
AgendaRoute: typeof AgendaRoute
|
||||||
JobsRoute: typeof JobsRoute
|
JobsRoute: typeof JobsRoute
|
||||||
|
ProjectsRoute: typeof ProjectsRouteWithChildren
|
||||||
|
TodosRoute: typeof TodosRoute
|
||||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
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': {
|
'/jobs': {
|
||||||
id: '/jobs'
|
id: '/jobs'
|
||||||
path: '/jobs'
|
path: '/jobs'
|
||||||
@ -68,6 +153,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof JobsRouteImport
|
preLoaderRoute: typeof JobsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agenda': {
|
||||||
|
id: '/agenda'
|
||||||
|
path: '/agenda'
|
||||||
|
fullPath: '/agenda'
|
||||||
|
preLoaderRoute: typeof AgendaRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@ -75,6 +167,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/projects/': {
|
||||||
|
id: '/projects/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/projects/'
|
||||||
|
preLoaderRoute: typeof ProjectsIndexRouteImport
|
||||||
|
parentRoute: typeof ProjectsRoute
|
||||||
|
}
|
||||||
'/settings/jobs': {
|
'/settings/jobs': {
|
||||||
id: '/settings/jobs'
|
id: '/settings/jobs'
|
||||||
path: '/settings/jobs'
|
path: '/settings/jobs'
|
||||||
@ -82,12 +181,36 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SettingsJobsRouteImport
|
preLoaderRoute: typeof SettingsJobsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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 = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
AgendaRoute: AgendaRoute,
|
||||||
JobsRoute: JobsRoute,
|
JobsRoute: JobsRoute,
|
||||||
|
ProjectsRoute: ProjectsRouteWithChildren,
|
||||||
|
TodosRoute: TodosRoute,
|
||||||
SettingsJobsRoute: SettingsJobsRoute,
|
SettingsJobsRoute: SettingsJobsRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
|
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
|
||||||
import { AccentDot, Label } from "@/design";
|
import { AccentDot, Label } from "@/design";
|
||||||
|
import { BottomNav } from "@/components/shell/BottomNav";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
@ -14,21 +15,31 @@ function RootLayout() {
|
|||||||
<AccentDot />
|
<AccentDot />
|
||||||
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||||
</Link>
|
</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="/">Dashboard</NavLink>
|
||||||
|
<NavLink to="/agenda">Agenda</NavLink>
|
||||||
|
<NavLink to="/todos">Todos</NavLink>
|
||||||
|
<NavLink to="/projects">Projets</NavLink>
|
||||||
<NavLink to="/jobs">Jobs</NavLink>
|
<NavLink to="/jobs">Jobs</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 />
|
<Outlet />
|
||||||
</main>
|
</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">
|
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||||
<Label>v0.0.0 · phase 1</Label>
|
<Label>v0.0.0 · phase 1</Label>
|
||||||
<Label>arthurbarre.fr</Label>
|
<Label>arthurbarre.fr</Label>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<BottomNav />
|
||||||
</div>
|
</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 { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Todo } from "@ordinarthur-os/shared";
|
||||||
import { api } from "@/api/client";
|
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 });
|
export const Route = createFileRoute("/")({ component: Dashboard });
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const health = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ["health"],
|
queryKey: ["todos", false],
|
||||||
queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }),
|
queryFn: () => api<Todo[]>("/todos"),
|
||||||
refetchInterval: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const active = (data ?? []).filter(
|
||||||
|
(t) => t.status !== "done" && t.status !== "archived",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-8 md:space-y-16">
|
||||||
<section>
|
<section className="space-y-6">
|
||||||
<Label prefix="[ 00 ]">PHASE 0 → 1</Label>
|
<Label prefix="[ 00 ]">ORDINARTHUR-OS</Label>
|
||||||
<BigHeading className="mt-4">
|
<BigHeading>
|
||||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
Parle. <em>Il capture.</em>
|
||||||
</BigHeading>
|
</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>
|
</section>
|
||||||
|
|
||||||
<SectionHeader
|
<section className="flex justify-center">
|
||||||
number="01"
|
<MagicButton />
|
||||||
label="STATUS"
|
</section>
|
||||||
title="Backend handshake"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GridFrame cols={2}>
|
<MedsSection />
|
||||||
<div className="p-6 space-y-3">
|
|
||||||
<Label>API HEALTH</Label>
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between border-b border-ink pb-3">
|
||||||
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
|
<Label prefix="[ 01 ]">EN COURS</Label>
|
||||||
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
|
<Link
|
||||||
</DataChip>
|
to="/todos"
|
||||||
{health.data && (
|
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink"
|
||||||
<span className="font-mono text-[11px] text-muted">
|
>
|
||||||
v{health.data.version} · uptime {health.data.uptime}s
|
Tout voir →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</li>
|
||||||
</div>
|
))}
|
||||||
<div className="p-6 space-y-3">
|
{active.length > 5 && (
|
||||||
<Label>STACK</Label>
|
<li className="px-4 py-2 font-mono text-[10px] uppercase tracking-label text-muted">
|
||||||
<div className="flex flex-wrap gap-2">
|
+ {active.length - 5} autre{active.length - 5 !== 1 ? "s" : ""}
|
||||||
<DataChip>VITE · REACT</DataChip>
|
</li>
|
||||||
<DataChip>NESTJS</DataChip>
|
)}
|
||||||
<DataChip>SUPABASE</DataChip>
|
</ul>
|
||||||
<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>
|
</section>
|
||||||
|
</div>
|
||||||
<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">
|
function EmptyRow({ text }: { text: string }) {
|
||||||
La Phase 1 est la première verticale métier: ingestion backend, listing éditorial,
|
return (
|
||||||
filtres de recherche et critères intégrés directement dans la vue jobs.
|
<div className="border border-ink px-4 py-6">
|
||||||
</p>
|
<p className="font-sans text-sm text-muted">{text}</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"
|
|
||||||
>
|
|
||||||
Ouvrir les jobs
|
|
||||||
</a>
|
|
||||||
</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>
|
|
||||||
</div>
|
</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.
|
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
|
## Ordre d'application initial
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl apply -f namespace.yaml
|
kubectl apply -f namespace.yaml
|
||||||
# Copier secrets.template.yaml -> secrets.yaml, remplir, puis :
|
# Copier secrets.template.yaml -> secrets.yaml, remplir, puis :
|
||||||
kubectl apply -f secrets.yaml
|
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 api.deployment.yaml
|
||||||
kubectl apply -f pwa.deployment.yaml
|
kubectl apply -f pwa.deployment.yaml
|
||||||
kubectl apply -f ingress.yaml
|
kubectl apply -f ingress.yaml
|
||||||
@ -20,5 +31,7 @@ kubectl apply -f backup.cronjob.yaml
|
|||||||
- Cluster issuer cert-manager (`letsencrypt-prod` ?)
|
- Cluster issuer cert-manager (`letsencrypt-prod` ?)
|
||||||
- Entrée Traefik (`websecure` ?)
|
- Entrée Traefik (`websecure` ?)
|
||||||
- DNS : `os.arthurbarre.fr` et `api.os.arthurbarre.fr` doivent pointer sur l'IP du load-balancer k3s
|
- 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)
|
- Bucket S3-compatible pour les backups (B2 / Scaleway / autre)
|
||||||
- Image registry : Gitea CR (défaut) — credentials pull peuvent nécessiter un `imagePullSecrets`
|
- 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:
|
template:
|
||||||
metadata: { labels: { app: api } }
|
metadata: { labels: { app: api } }
|
||||||
spec:
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
containers:
|
containers:
|
||||||
- name: api
|
- name: api
|
||||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
|
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports: [{ containerPort: 3000 }]
|
ports: [{ containerPort: 3000 }]
|
||||||
envFrom:
|
envFrom:
|
||||||
@ -19,6 +21,7 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- { name: NODE_ENV, value: production }
|
- { name: NODE_ENV, value: production }
|
||||||
- { name: PORT, value: "3000" }
|
- { name: PORT, value: "3000" }
|
||||||
|
- { name: PWA_URL, value: "https://os.arthurbarre.fr" }
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: { path: /health, port: 3000 }
|
httpGet: { path: /health, port: 3000 }
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
@ -37,5 +40,9 @@ metadata:
|
|||||||
name: api
|
name: api
|
||||||
namespace: ordinarthur-os
|
namespace: ordinarthur-os
|
||||||
spec:
|
spec:
|
||||||
|
type: NodePort
|
||||||
selector: { app: api }
|
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.
|
# Backup quotidien du Postgres ordinarthur-os.
|
||||||
# `PGURL` et `RCLONE_REMOTE` à fournir via secret séparé `ordinarthur-os-backup-secrets`
|
# Secrets dans `ordinarthur-os-backup-secrets` :
|
||||||
# (voir secrets.template.yaml — à splitter quand le bucket S3 est choisi avec Arthur).
|
# PGURL → chaîne `postgres://…` (copie de DATABASE_URL)
|
||||||
|
# RCLONE_REMOTE → ex. `b2:ordinarthur-os-backups`
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: CronJob
|
kind: CronJob
|
||||||
metadata:
|
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:
|
template:
|
||||||
metadata: { labels: { app: pwa } }
|
metadata: { labels: { app: pwa } }
|
||||||
spec:
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry
|
||||||
containers:
|
containers:
|
||||||
- name: pwa
|
- name: pwa
|
||||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
|
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports: [{ containerPort: 80 }]
|
ports: [{ containerPort: 80 }]
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: { path: /, port: 80 }
|
httpGet: { path: /, port: 80 }
|
||||||
initialDelaySeconds: 3
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 20m, memory: 32Mi }
|
requests: { cpu: 20m, memory: 32Mi }
|
||||||
limits: { cpu: 100m, memory: 128Mi }
|
limits: { cpu: 100m, memory: 128Mi }
|
||||||
@ -27,5 +30,9 @@ metadata:
|
|||||||
name: pwa
|
name: pwa
|
||||||
namespace: ordinarthur-os
|
namespace: ordinarthur-os
|
||||||
spec:
|
spec:
|
||||||
|
type: NodePort
|
||||||
selector: { app: pwa }
|
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.
|
# 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
|
# kubectl -n ordinarthur-os apply -f secrets.yaml
|
||||||
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
@ -9,9 +10,7 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
API_BEARER_TOKEN: ""
|
API_BEARER_TOKEN: ""
|
||||||
SUPABASE_URL: "https://supabase.arthurbarre.fr"
|
DATABASE_URL: "postgres://ordinarthur:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os"
|
||||||
SUPABASE_SERVICE_ROLE_KEY: ""
|
|
||||||
SUPABASE_SCHEMA: "ordinarthur_os"
|
|
||||||
MISTRAL_API_KEY: ""
|
MISTRAL_API_KEY: ""
|
||||||
MISTRAL_MODEL: "mistral-small-latest"
|
MISTRAL_MODEL: "mistral-small-latest"
|
||||||
GROQ_API_KEY: ""
|
GROQ_API_KEY: ""
|
||||||
@ -22,3 +21,14 @@ stringData:
|
|||||||
ICAL_FEED_SECRET: ""
|
ICAL_FEED_SECRET: ""
|
||||||
TELEGRAM_BOT_TOKEN: ""
|
TELEGRAM_BOT_TOKEN: ""
|
||||||
TELEGRAM_WEBHOOK_SECRET: ""
|
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
|
# @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)
|
- `src/schema/` — définitions Drizzle (TypeScript). Une table = un fichier, réexporté depuis `schema/index.ts`.
|
||||||
- `0002_jobs.sql` — tables `jobs`, `job_search_criteria` (Phase 1)
|
- `src/client.ts` — factory `createDb(connectionString)` utilisée par l'API.
|
||||||
- `0003_todos.sql` — table `todos` + `client_mutations` (Phase 2)
|
- `src/migrate.ts` — runner des migrations (consomme `DATABASE_URL`).
|
||||||
- `0004_projects.sql` — `projects`, `project_steps`, `project_ideas` (Phase 3)
|
- `drizzle.config.ts` — config drizzle-kit.
|
||||||
- `0005_agenda.sql` — `calendar_events`, `google_oauth_tokens` (Phase 4)
|
- `migrations/` — SQL versionné (`0000_init.sql`, `0001_jobs.sql`, …) + `meta/_journal.json` exploité par le runner.
|
||||||
- `0006_ai.sql` — `ai_actions` (Phase 5)
|
|
||||||
- `0007_health.sql` — `daily_checkins` (Phase 7)
|
## 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",
|
"name": "@ordinarthur-os/db",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"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",
|
"name": "@ordinarthur-os/shared",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"main": "./dist/index.js",
|
||||||
"main": "./src/index.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"types": "./src/index.ts",
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -113,6 +113,177 @@ export const JobSearchCriteriaUpsert = z.object({
|
|||||||
});
|
});
|
||||||
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
|
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)
|
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -123,7 +294,7 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
args: z.object({
|
args: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().optional(),
|
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(),
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
project_id: z.string().uuid().optional(),
|
project_id: z.string().uuid().optional(),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
@ -148,8 +319,8 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
fn: z.literal("create_calendar_event"),
|
fn: z.literal("create_calendar_event"),
|
||||||
args: z.object({
|
args: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
starts_at: z.string().datetime(),
|
starts_at: z.string().datetime({ offset: true }),
|
||||||
ends_at: z.string().datetime(),
|
ends_at: z.string().datetime({ offset: true }),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
@ -160,5 +331,122 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
|||||||
note: z.string().optional(),
|
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>;
|
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": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true,
|
||||||
|
"dependsOn": ["^build"]
|
||||||
},
|
},
|
||||||
"lint": {},
|
"lint": {},
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user