init
This commit is contained in:
commit
bc0c15873f
43
.gitea/workflows/deploy.yml
Normal file
43
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,43 @@
|
||||
# Squelette à aligner sur le skill /deploy d'Arthur.
|
||||
# - Build images api + pwa
|
||||
# - Push vers Gitea Container Registry
|
||||
# - kubectl set image (ou apply via kustomize plus tard)
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
|
||||
- name: Login Gitea Container Registry
|
||||
run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build & push images
|
||||
run: |
|
||||
API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
||||
PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
||||
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
||||
docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" .
|
||||
docker push "$API_TAG"
|
||||
docker push "$PWA_TAG"
|
||||
echo "API_TAG=$API_TAG" >> $GITHUB_ENV
|
||||
echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy on k3s
|
||||
env:
|
||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
||||
run: |
|
||||
mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
|
||||
kubectl -n ordinarthur-os set image deploy/api api=$API_TAG
|
||||
kubectl -n ordinarthur-os set image deploy/pwa pwa=$PWA_TAG
|
||||
kubectl -n ordinarthur-os rollout status deploy/api --timeout=120s
|
||||
kubectl -n ordinarthur-os rollout status deploy/pwa --timeout=120s
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
.vite
|
||||
.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
541
ARCHITECTURE.md
Normal file
541
ARCHITECTURE.md
Normal file
@ -0,0 +1,541 @@
|
||||
# ordinarthur-os — Architecture
|
||||
|
||||
## 1. Topologie haut niveau
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ ┌──────────────────────────┐
|
||||
│ PWA (Vite+React) │ HTTPS │ NestJS API (BFF unique) │
|
||||
│ os.arthurbarre.fr │──────▶ │ api.os.arthurbarre.fr │
|
||||
│ Installable iOS / offline │ │ │
|
||||
└──────────────────────────────┘ └─────────────┬────────────┘
|
||||
│
|
||||
┌──────────────┬──────────────────┼────────────────┬──────────────┐
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌────────────┐ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ Supabase │ │ Mistral AI │ │ Groq Whisper │ │ Google Cal │ │ Telegram │
|
||||
│ self-host │ │ (small) │ │ (STT FR) │ │ OAuth │ │ Bot API │
|
||||
│ schema │ │ function- │ │ │ │ │ │ │
|
||||
│ ordinarthur│ │ calling │ │ │ │ │ │ │
|
||||
│ _os │ │ │ │ │ │ │ │ │
|
||||
└────────────┘ └─────────────┘ └──────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
La PWA ne parle **jamais** directement à Supabase ni aux APIs externes. Tout passe par le BFF NestJS, protégé par bearer token.
|
||||
|
||||
## 2. Monorepo
|
||||
|
||||
```
|
||||
ordinarthur-os/
|
||||
├── apps/
|
||||
│ ├── pwa/ # Vite + React + TanStack Router/Query + Tailwind + shadcn
|
||||
│ │ ├── public/
|
||||
│ │ │ ├── manifest.webmanifest
|
||||
│ │ │ └── icons/ # 192, 512, maskable, apple-touch
|
||||
│ │ └── src/
|
||||
│ │ ├── routes/ # / /jobs /todos /projects /agenda /health /settings/*
|
||||
│ │ ├── components/ # Composants métier
|
||||
│ │ ├── design/ # Tokens + primitives éditoriales
|
||||
│ │ ├── api/ # Client HTTP typé, zod parse
|
||||
│ │ ├── offline/ # sw.ts, dexie.ts, mutationQueue.ts
|
||||
│ │ ├── lib/ # Utils
|
||||
│ │ └── main.tsx
|
||||
│ │
|
||||
│ └── api/ # NestJS
|
||||
│ └── src/
|
||||
│ ├── modules/
|
||||
│ │ ├── auth/ # BearerGuard, AuthController
|
||||
│ │ ├── jobs/
|
||||
│ │ ├── todos/
|
||||
│ │ ├── projects/
|
||||
│ │ ├── agenda/
|
||||
│ │ ├── health/
|
||||
│ │ ├── ai/ # command, voice, function dispatcher
|
||||
│ │ ├── telegram/
|
||||
│ │ └── sync/ # replay client_mutations, dedup
|
||||
│ ├── db/ # supabase client factory
|
||||
│ ├── config/ # env schema (zod)
|
||||
│ └── main.ts
|
||||
│
|
||||
├── packages/
|
||||
│ ├── shared/ # Types + zod DTOs partagés pwa/api
|
||||
│ └── db/
|
||||
│ └── migrations/ # 0001_schema.sql, 0002_jobs.sql, …
|
||||
│
|
||||
├── deploy/
|
||||
│ └── k8s/ # Manifests à aligner sur conf Gitea d'Arthur
|
||||
│ ├── api.deployment.yaml
|
||||
│ ├── pwa.deployment.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── secrets.template.yaml
|
||||
│ └── backup.cronjob.yaml
|
||||
│
|
||||
├── .gitea/workflows/
|
||||
│ └── deploy.yml # À aligner sur le skill /deploy d'Arthur
|
||||
│
|
||||
├── pnpm-workspace.yaml
|
||||
├── turbo.json
|
||||
├── CLAUDE.md # Pointe vers PLAN.md + ARCHITECTURE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 3. Design system (Tailwind + primitives)
|
||||
|
||||
### Tokens (tailwind.config.ts)
|
||||
|
||||
```ts
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#F5F1EA',
|
||||
ink: '#0F0F0F',
|
||||
muted: '#8A8579',
|
||||
accent: '#FF4A1C',
|
||||
surface: '#FFFFFF',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Inter Tight"', 'system-ui', 'sans-serif'],
|
||||
mono: ['"Space Mono"', '"JetBrains Mono"', 'monospace'],
|
||||
},
|
||||
letterSpacing: {
|
||||
tightest: '-0.03em',
|
||||
label: '0.06em',
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Primitives éditoriales
|
||||
|
||||
- `<Label prefix="[ 01 ]">À PROPOS</Label>` → mono, uppercase, 11px, tracking-label, muted
|
||||
- `<SectionHeader number="01" label="À PROPOS" title="Le titre" />` → grid bordé
|
||||
- `<GridFrame cols={12} children={...} />` → wrapper avec `border border-ink` et `divide-ink`
|
||||
- `<DataChip dotColor="accent|ink">REACT</DataChip> ` → les cases de stack du portfolio
|
||||
- `<MetaRow label="RÔLE" value="FULLSTACK · REACT · NODE" />` → clé en muted, valeur en ink
|
||||
- `<BigHeading>Développeur <em>qui construit</em> des outils.</BigHeading>` → font-sans font-light, clamp responsive, italic = orange
|
||||
|
||||
### Règles visuelles
|
||||
|
||||
- Bordures partout : sections, grid cells, nav
|
||||
- Pas d'ombre portée (shadow-none)
|
||||
- Imagery grayscale par défaut
|
||||
- Italique = accent orange (convention portfolio)
|
||||
- Motion minimal (fade ≤ 200ms), pas d'animation flashy
|
||||
- `accent-pulse` : petit dot orange qui pulse doucement pour marquer "live/disponible" (voir header portfolio)
|
||||
|
||||
## 4. Schéma Postgres `ordinarthur_os`
|
||||
|
||||
```sql
|
||||
create schema if not exists ordinarthur_os;
|
||||
set search_path to ordinarthur_os;
|
||||
|
||||
-- ============================================================
|
||||
-- JOBS
|
||||
-- ============================================================
|
||||
create table jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
source text not null, -- 'Indeed','WeLoveDevs',...
|
||||
source_url text not null unique, -- clé de dedup
|
||||
title text not null,
|
||||
company text,
|
||||
description text,
|
||||
location text,
|
||||
remote_type text check (remote_type in ('remote','hybrid','onsite')),
|
||||
salary_min int,
|
||||
salary_max int,
|
||||
stack text[] default '{}',
|
||||
apply_url text,
|
||||
first_seen_at timestamptz default now(),
|
||||
last_seen_at timestamptz default now(),
|
||||
archived boolean default false,
|
||||
starred boolean default false,
|
||||
applied_at timestamptz,
|
||||
notes text
|
||||
);
|
||||
create index on jobs(last_seen_at desc);
|
||||
create index on jobs(archived);
|
||||
|
||||
create table job_search_criteria (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text,
|
||||
titles text[] default '{}',
|
||||
locations text[] default '{}',
|
||||
stack text[] default '{}',
|
||||
remote_types text[] default '{}',
|
||||
salary_min int,
|
||||
active boolean default true,
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- PROJECTS
|
||||
-- ============================================================
|
||||
create table projects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
kind text check (kind in ('freelance','perso')) default 'perso',
|
||||
status text default 'active',
|
||||
description text,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
create table project_steps (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references projects(id) on delete cascade,
|
||||
title text not null,
|
||||
description text,
|
||||
status text default 'backlog'
|
||||
check (status in ('backlog','todo','doing','review','done')),
|
||||
position int default 0,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
create index on project_steps(project_id, status, position);
|
||||
|
||||
create table project_ideas (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references projects(id) on delete cascade,
|
||||
content text not null,
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- TODOS
|
||||
-- ============================================================
|
||||
create table todos (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
title text not null,
|
||||
description text, -- markdown
|
||||
status text default 'inbox'
|
||||
check (status in ('inbox','todo','doing','done','archived')),
|
||||
priority smallint check (priority between 0 and 3),
|
||||
due_at timestamptz,
|
||||
tags text[] default '{}',
|
||||
project_id uuid references projects(id) on delete set null,
|
||||
checklist jsonb default '[]'::jsonb, -- [{text, done}]
|
||||
energy text check (energy in ('low','med','high')),
|
||||
context text, -- '@home','@laptop',…
|
||||
recurrence text, -- rrule string
|
||||
ticket_url text,
|
||||
verification_steps text[] default '{}',
|
||||
ai_enriched boolean default false,
|
||||
created_at timestamptz default now(),
|
||||
completed_at timestamptz
|
||||
);
|
||||
create index on todos(status);
|
||||
create index on todos(due_at);
|
||||
|
||||
-- ============================================================
|
||||
-- AGENDA
|
||||
-- ============================================================
|
||||
create table calendar_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
google_event_id text unique,
|
||||
title text not null,
|
||||
description text,
|
||||
location text,
|
||||
starts_at timestamptz not null,
|
||||
ends_at timestamptz not null,
|
||||
all_day boolean default false,
|
||||
source text default 'ordinarthur-os', -- 'ordinarthur-os' | 'google'
|
||||
created_at timestamptz default now(),
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
create index on calendar_events(starts_at);
|
||||
|
||||
create table google_oauth_tokens ( -- single-row pour Arthur
|
||||
id smallint primary key default 1 check (id = 1),
|
||||
access_token text not null,
|
||||
refresh_token text not null,
|
||||
expires_at timestamptz not null,
|
||||
calendar_id text -- id du calendar "ordinarthur-os"
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- HEALTH
|
||||
-- ============================================================
|
||||
create table daily_checkins (
|
||||
day date primary key,
|
||||
meds_taken boolean default false,
|
||||
note text,
|
||||
updated_at timestamptz default now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- AI AUDIT
|
||||
-- ============================================================
|
||||
create table ai_actions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
input_text text,
|
||||
transcript text, -- si voice
|
||||
function_name text,
|
||||
function_args jsonb,
|
||||
result jsonb,
|
||||
status text check (status in ('proposed','confirmed','cancelled','failed')),
|
||||
created_at timestamptz default now()
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- OFFLINE SYNC
|
||||
-- ============================================================
|
||||
create table client_mutations (
|
||||
client_mutation_id text primary key, -- uuid généré côté PWA
|
||||
applied_at timestamptz default now(),
|
||||
result jsonb
|
||||
);
|
||||
```
|
||||
|
||||
**RLS** : activée partout avec policy unique `using (auth.role() = 'service_role')`. Seul le Nest (avec la service key) peut lire/écrire. La PWA est derrière le bearer Nest.
|
||||
|
||||
## 5. API NestJS — routes
|
||||
|
||||
Middleware global : `BearerGuard` sauf `/health`, `/telegram/webhook` (signé autrement), `/agenda/ical/:secret.ics` (secret URL).
|
||||
|
||||
```
|
||||
GET /health { ok: true, version }
|
||||
POST /auth/verify
|
||||
|
||||
# JOBS
|
||||
POST /jobs/ingest { jobs: JobIngestDto[] } → { inserted, updated }
|
||||
GET /jobs ?archived&remote_type&stack[]&since
|
||||
PATCH /jobs/:id { starred?, archived?, applied_at? }
|
||||
GET /jobs/criteria
|
||||
PUT /jobs/criteria/:id
|
||||
POST /jobs/criteria
|
||||
DELETE /jobs/criteria/:id
|
||||
|
||||
# TODOS
|
||||
GET /todos
|
||||
POST /todos
|
||||
PATCH /todos/:id
|
||||
DELETE /todos/:id
|
||||
POST /todos/:id/ai-enrich → renvoie un draft (non sauvé)
|
||||
POST /todos/:id/ai-enrich/apply { draft } → applique
|
||||
|
||||
# PROJECTS
|
||||
GET /projects
|
||||
POST /projects
|
||||
PATCH /projects/:id
|
||||
DELETE /projects/:id
|
||||
GET /projects/:id/steps
|
||||
POST /projects/:id/steps
|
||||
PATCH /projects/:id/steps/:stepId
|
||||
DELETE /projects/:id/steps/:stepId
|
||||
POST /projects/:id/steps/reorder { order: uuid[] }
|
||||
GET /projects/:id/ideas
|
||||
POST /projects/:id/ideas
|
||||
DELETE /projects/:id/ideas/:ideaId
|
||||
|
||||
# AGENDA
|
||||
GET /agenda/events ?from&to
|
||||
POST /agenda/events
|
||||
PATCH /agenda/events/:id
|
||||
DELETE /agenda/events/:id
|
||||
GET /agenda/google/oauth/start
|
||||
GET /agenda/google/oauth/callback
|
||||
POST /agenda/google/sync
|
||||
GET /agenda/ical/:secret.ics → text/calendar
|
||||
|
||||
# HEALTH
|
||||
GET /health-tab/today { day, meds_taken, note }
|
||||
POST /health-tab/today/toggle → flips meds_taken, returns row
|
||||
GET /health-tab/history?days=30
|
||||
|
||||
# AI
|
||||
POST /ai/command { text } → { actions: ProposedAction[] }
|
||||
POST /ai/voice multipart audio → { transcript, actions }
|
||||
POST /ai/command/confirm { actions: ProposedAction[] } → { results }
|
||||
|
||||
# TELEGRAM
|
||||
POST /telegram/webhook (header X-Telegram-Bot-Api-Secret-Token)
|
||||
POST /telegram/set-webhook (admin, one-shot)
|
||||
|
||||
# SYNC (offline)
|
||||
POST /sync/replay { mutations: ClientMutation[] } → { acked, errors }
|
||||
```
|
||||
|
||||
### AI function calling — fonctions exposées à Mistral
|
||||
|
||||
```ts
|
||||
type ProposedAction =
|
||||
| { fn: 'create_todo', args: { title, description?, due_at?, priority?, project_id?, tags? } }
|
||||
| { fn: 'add_project_idea', args: { project_id, content } }
|
||||
| { fn: 'add_project_step', args: { project_id, title, status? } }
|
||||
| { fn: 'create_calendar_event', args: { title, starts_at, ends_at, location?, description? } }
|
||||
| { fn: 'toggle_daily_checkin', args: { note? } };
|
||||
```
|
||||
|
||||
Flow garanti : l'API **ne jamais** exécute une action directement. Elle renvoie un `ProposedAction[]` à la PWA, qui affiche une modal de confirmation. Seul `/ai/command/confirm` écrit en DB.
|
||||
|
||||
## 6. PWA — routing & pages
|
||||
|
||||
```
|
||||
/ Dashboard (events du jour, todos due today, médocs, bouton 🎤, Cmd-K)
|
||||
/jobs Liste des offres (filtres toutes/remote/hybrid/marseille)
|
||||
/jobs/:id Détail + actions (star, archive, applied)
|
||||
/todos Inbox + filtres
|
||||
/projects Liste
|
||||
/projects/:id Détail + kanban + idées
|
||||
/agenda Vue semaine / jour
|
||||
/health Toggle du jour + heatmap 30j
|
||||
/settings/jobs Édition des critères (lus par le scheduled task)
|
||||
/settings/account Bearer token, health checks, connect Google, connect Telegram
|
||||
```
|
||||
|
||||
### Offline
|
||||
|
||||
- Service worker (Workbox) : précache le shell, stale-while-revalidate sur GET `/api/*`
|
||||
- Dexie tables : `todos_cache`, `projects_cache`, `pendingMutations`
|
||||
- Toute mutation offline génère un `client_mutation_id` (uuid v4) et s'empile dans `pendingMutations`
|
||||
- Au retour online : `POST /sync/replay` séquentiel, l'API déduplique via `client_mutations` (idempotence)
|
||||
- UI optimiste via `useMutation` TanStack Query (`onMutate` update cache, `onError` rollback)
|
||||
|
||||
## 7. Voice magic button
|
||||
|
||||
1. `MediaRecorder` dans le navigateur → blob WebM/Opus
|
||||
2. `POST /ai/voice` (multipart) vers NestJS
|
||||
3. Nest → Groq Whisper (`whisper-large-v3-turbo`, lang=fr) → transcript
|
||||
4. Nest → Mistral chat completions avec `tools` (les fonctions ci-dessus) + system prompt FR
|
||||
5. Mistral renvoie 1..n `tool_calls` → Nest wrappe en `ProposedAction[]`, log dans `ai_actions` avec status=`proposed`
|
||||
6. Réponse API : `{ transcript, actions }`
|
||||
7. PWA affiche modal : "Tu veux faire : …" avec boutons **Confirmer** / **Annuler** / **Éditer**
|
||||
8. Sur Confirmer → `POST /ai/command/confirm` → Nest exécute + update `ai_actions.status='confirmed'`
|
||||
|
||||
## 8. Déploiement k3s
|
||||
|
||||
### Ingress
|
||||
|
||||
```yaml
|
||||
# deploy/k8s/ingress.yaml (exemple, à aligner sur conf Traefik d'Arthur)
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ordinarthur-os
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
spec:
|
||||
tls:
|
||||
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
|
||||
secretName: ordinarthur-os-tls
|
||||
rules:
|
||||
- host: os.arthurbarre.fr
|
||||
http:
|
||||
paths: [{ path: /, pathType: Prefix, backend: { service: { name: pwa, port: { number: 80 } } } }]
|
||||
- host: api.os.arthurbarre.fr
|
||||
http:
|
||||
paths: [{ path: /, pathType: Prefix, backend: { service: { name: api, port: { number: 3000 } } } }]
|
||||
```
|
||||
|
||||
### Secrets k8s attendus
|
||||
|
||||
`ordinarthur-os-secrets` :
|
||||
- `API_BEARER_TOKEN`
|
||||
- `SUPABASE_URL` (`https://supabase.arthurbarre.fr`)
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `MISTRAL_API_KEY`
|
||||
- `GROQ_API_KEY`
|
||||
- `GOOGLE_OAUTH_CLIENT_ID`
|
||||
- `GOOGLE_OAUTH_CLIENT_SECRET`
|
||||
- `GOOGLE_OAUTH_REDIRECT_URI` (`https://api.os.arthurbarre.fr/agenda/google/oauth/callback`)
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `TELEGRAM_WEBHOOK_SECRET`
|
||||
- `ICAL_FEED_SECRET`
|
||||
|
||||
### CronJob backup
|
||||
|
||||
```yaml
|
||||
# deploy/k8s/backup.cronjob.yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata: { name: ordinarthur-os-backup }
|
||||
spec:
|
||||
schedule: "0 3 * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: pgdump
|
||||
image: postgres:16-alpine
|
||||
envFrom: [{ secretRef: { name: ordinarthur-os-secrets } }]
|
||||
command: ["/bin/sh","-c"]
|
||||
args:
|
||||
- |
|
||||
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
|
||||
# upload vers S3/B2 via rclone (à confirmer avec Arthur)
|
||||
rclone copy /tmp/dump.gz remote:ordinarthur-os-backups/$(date +%F).gz
|
||||
```
|
||||
|
||||
### Pipeline Gitea (à aligner avec le skill `/deploy` d'Arthur)
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/deploy.yml — squelette
|
||||
on: { push: { branches: [main] } }
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
- name: Build images
|
||||
run: |
|
||||
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }} apps/api
|
||||
docker build -t gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }} apps/pwa
|
||||
- name: Push
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
|
||||
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
||||
docker push gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
||||
- name: Deploy
|
||||
run: |
|
||||
kubectl set image deploy/api api=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
||||
kubectl set image deploy/pwa pwa=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
||||
```
|
||||
|
||||
## 9. Sécurité
|
||||
|
||||
- TLS everywhere via cert-manager
|
||||
- Bearer token Nest : stocké seulement dans les secrets k8s, jamais dans le bundle PWA → la PWA demande le token à l'utilisateur (écran d'onboarding) et le stocke dans `localStorage` (accès par Arthur uniquement sur son device)
|
||||
- Webhook Telegram : vérification de `X-Telegram-Bot-Api-Secret-Token`
|
||||
- Feed iCal : path contient un secret rotable (`ICAL_FEED_SECRET`)
|
||||
- Supabase : schema dédié + RLS service-role-only
|
||||
- Backups : quotidien, chiffrés au repos côté bucket, rétention 30 jours
|
||||
|
||||
## 10. Observabilité (phase ultérieure)
|
||||
|
||||
- Logs Nest en JSON → si Arthur a déjà Loki/Grafana, les envoyer là
|
||||
- Health probes Liveness / Readiness sur `/health`
|
||||
- Sentry optionnel plus tard
|
||||
|
||||
---
|
||||
|
||||
## Annexe — variables d'environnement
|
||||
|
||||
### `apps/api` (.env.example)
|
||||
|
||||
```
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
API_BEARER_TOKEN=
|
||||
SUPABASE_URL=https://supabase.arthurbarre.fr
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_SCHEMA=ordinarthur_os
|
||||
MISTRAL_API_KEY=
|
||||
MISTRAL_MODEL=mistral-small-latest
|
||||
GROQ_API_KEY=
|
||||
GROQ_STT_MODEL=whisper-large-v3-turbo
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
ICAL_FEED_SECRET=
|
||||
```
|
||||
|
||||
### `apps/pwa` (.env.example)
|
||||
|
||||
```
|
||||
VITE_API_BASE_URL=https://api.os.arthurbarre.fr
|
||||
```
|
||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@ -0,0 +1,29 @@
|
||||
# CLAUDE.md — ordinarthur-os
|
||||
|
||||
Avant toute action, lire dans cet ordre :
|
||||
|
||||
1. [`README.md`](./README.md)
|
||||
2. [`PLAN.md`](./PLAN.md)
|
||||
3. [`ARCHITECTURE.md`](./ARCHITECTURE.md)
|
||||
|
||||
## Règles non-négociables
|
||||
|
||||
- **Pas de Next.js, pas de Vercel.** Stack = Vite/React + NestJS, déploiement k3s.
|
||||
- **L'IA ne mute jamais la DB sans clic de confirmation utilisateur.** Le backend renvoie des `ProposedAction[]`, la PWA confirme via modal, puis `/ai/command/confirm` exécute.
|
||||
- **Single-user.** Bearer token statique, pas de multi-tenant.
|
||||
- **Design = portfolio arthurbarre.fr** (cream `#F5F1EA`, ink `#0F0F0F`, accent orange `#FF4A1C`, mono labels, bordures, italique = orange). PAS le violet/cyan du HTML jobs.
|
||||
- **FR only** côté IA (prompts + STT lang=fr).
|
||||
- **Schéma Postgres dédié `ordinarthur_os`** dans Supabase self-hosted (`supabase.arthurbarre.fr`).
|
||||
|
||||
## Phases
|
||||
|
||||
Voir `PLAN.md`. Implémentation séquentielle Phase 0 → 7. Phase 8 (finance) reportée.
|
||||
|
||||
## Conventions repo
|
||||
|
||||
- Monorepo pnpm + Turborepo
|
||||
- `apps/pwa` Vite + React + TanStack Router/Query + Tailwind + shadcn
|
||||
- `apps/api` NestJS (modules par domaine), `@supabase/supabase-js` (pas d'ORM)
|
||||
- `packages/shared` types + zod DTOs partagés PWA ↔ API
|
||||
- `packages/db/migrations` SQL versionné, appliqué manuellement sur Supabase pour l'instant
|
||||
- Pas de fichier `.env` commité, juste `.env.example`
|
||||
119
PLAN.md
Normal file
119
PLAN.md
Normal file
@ -0,0 +1,119 @@
|
||||
# ordinarthur-os — Plan d'implémentation
|
||||
|
||||
> **Status** : planning terminé 2026-04-15. À implémenter via Claude Code (Sonnet).
|
||||
|
||||
## Vision produit
|
||||
|
||||
Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabiliser**.
|
||||
|
||||
- Dashboard clair de ce qu'il fait / veut faire
|
||||
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
||||
- Pas de "weekly review" automatique, pas de nudges
|
||||
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → transcription → création d'une todo / idée / étape projet / événement agenda, avec validation explicite avant écriture en base
|
||||
|
||||
## Principes directeurs
|
||||
|
||||
1. **Self-hosted, open-source**. Pas de Vercel, pas de Next.js. Tout tourne sur le k3s d'Arthur.
|
||||
2. **Single-user**. Pas de multi-tenant, pas d'invitations, pas de partage. Bearer token unique pour protéger l'API.
|
||||
3. **PWA installable iOS**. Vite + React, pas de SSR. Service worker + mutation queue pour l'offline.
|
||||
4. **BFF unique**. La PWA ne parle qu'au NestJS. Le Nest parle à Supabase, Mistral, Groq, Google Calendar, Telegram.
|
||||
5. **Design éditorial / Swiss-brutalist** — mirror du portfolio arthurbarre.fr (cream, ink, orange, borders, mono labels).
|
||||
|
||||
## Stack verrouillée
|
||||
|
||||
| Couche | Choix |
|
||||
| --- | --- |
|
||||
| Monorepo | pnpm workspaces + Turborepo |
|
||||
| Frontend | Vite + React 18 + TanStack Router + TanStack Query + Tailwind + shadcn/ui |
|
||||
| Backend | NestJS + `@supabase/supabase-js` (pas d'ORM) |
|
||||
| DB | Postgres via Supabase self-hosted, schéma dédié `ordinarthur_os` |
|
||||
| Auth | Bearer token statique (single-user), middleware Nest |
|
||||
| IA LLM | Mistral `mistral-small-latest` (low-cost) via API |
|
||||
| STT | Groq `whisper-large-v3-turbo` |
|
||||
| Bot | Telegram Bot API (webhook) |
|
||||
| Calendar | Google Calendar API (Apple souscrit au calendar Google via webcal) |
|
||||
| Langue IA | FR only |
|
||||
| Déploiement | Images Docker → Gitea Container Registry → pipeline Gitea Actions → k3s (Traefik + cert-manager supposés présents) |
|
||||
| Backups | CronJob k8s `pg_dump --schema=ordinarthur_os` quotidien → stockage S3-compatible (à confirmer avec Arthur) |
|
||||
|
||||
## Roadmap — ordre d'implémentation
|
||||
|
||||
### Phase 0 — Scaffold (prio immédiate)
|
||||
- Monorepo `pnpm-workspace.yaml`, `turbo.json`
|
||||
- `apps/pwa` : Vite + React + Tailwind + shadcn + manifest PWA + service worker placeholder + routing TanStack
|
||||
- `apps/api` : NestJS + module `health` + middleware bearer + client supabase initialisé
|
||||
- `packages/shared` : types et zod schemas partagés
|
||||
- `packages/db` : premier fichier de migration SQL créant le schéma `ordinarthur_os`
|
||||
- `deploy/k8s` : manifests génériques (à adapter ensuite à la conf Gitea/Traefik d'Arthur)
|
||||
- Design system : composants primitifs (`<Label>`, `<SectionHeader>`, `<GridFrame>`, `<DataChip>`, `<MetaRow>`) qui reproduisent le style arthurbarre.fr
|
||||
- Routes `GET /health` et `POST /auth/verify`
|
||||
|
||||
### Phase 1 — Jobs (prio haute, remontée)
|
||||
- Migration : tables `jobs`, `job_search_criteria`
|
||||
- API : `POST /jobs/ingest` (bearer), `GET /jobs`, `PATCH /jobs/:id`, `GET/PUT /jobs/criteria`
|
||||
- PWA : route `/jobs` avec filtres (toutes / remote / hybrid / marseille), rendu éditorial façon portfolio (lignes tableau, pas des cards violettes)
|
||||
- PWA : route `/settings/jobs` pour éditer les critères (titres, localisations, stack[], remote_types[], salary_min, active)
|
||||
- Dedup : clé unique `source_url`, update `last_seen_at` si déjà vu
|
||||
- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`)
|
||||
- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h
|
||||
|
||||
### Phase 2 — Todos riches
|
||||
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet)
|
||||
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation)
|
||||
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline
|
||||
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations`
|
||||
|
||||
### Phase 3 — Projets + Kanban
|
||||
- Migrations : `projects`, `project_steps`, `project_ideas`
|
||||
- API : CRUD projets, CRUD steps, reorder, CRUD ideas
|
||||
- PWA : route `/projects`, détail projet avec kanban (colonnes backlog/todo/doing/review/done), zone idées
|
||||
|
||||
### Phase 4 — Agenda + Google Calendar sync
|
||||
- Migration : `calendar_events`
|
||||
- API : OAuth Google (scope calendar), CRUD events avec sync bi-directionnelle, endpoint `/agenda/ical/:secret.ics` pour souscription Apple
|
||||
- PWA : route `/agenda` vue semaine + jour
|
||||
|
||||
### Phase 5 — IA : bouton texte + voice magic button
|
||||
- Migration : `ai_actions` (log d'audit)
|
||||
- API : `POST /ai/command` (texte → function calling Mistral → plan), `POST /ai/voice` (audio → Groq Whisper → texte → Mistral → plan), `POST /ai/command/confirm` (applique après clic user)
|
||||
- Fonctions exposées au LLM : `create_todo`, `add_project_idea`, `add_project_step`, `create_calendar_event`, `toggle_daily_checkin`
|
||||
- PWA : bouton "🎤 Parler" sur le dashboard, `Cmd-K` pour la barre de commande texte, modal de confirmation avant exécution
|
||||
|
||||
### Phase 6 — Telegram bot
|
||||
- Webhook Nest `/telegram/webhook` signé (header `X-Telegram-Bot-Api-Secret-Token`)
|
||||
- Commandes : `/today` (events + todos du jour), `/todo <texte>` (crée une todo), messages vocaux traités comme le voice magic button
|
||||
- Rappel quotidien optionnel à une heure configurable (simple message, pas d'action auto)
|
||||
|
||||
### Phase 7 — Health tab
|
||||
- Migration : `daily_checkins (day date PK, meds_taken boolean, note text?)`
|
||||
- API : `GET /health/today`, `POST /health/today/toggle`
|
||||
- PWA : slider/toggle simple "médocs pris aujourd'hui" + historique 30j minimaliste
|
||||
|
||||
### Phase 8 — Finance (reporté)
|
||||
- Revolut perso n'a pas d'API → passer par GoCardless Bank Account Data (ex-Nordigen)
|
||||
- À traiter uniquement quand les phases 0–7 sont stables
|
||||
|
||||
## Handoff Claude Code
|
||||
|
||||
Pour reprendre ce projet avec Claude Code (Sonnet) :
|
||||
|
||||
1. Lire `README.md`, `PLAN.md`, `ARCHITECTURE.md` dans cet ordre
|
||||
2. Pointer `CLAUDE.md` du repo vers ces docs
|
||||
3. Respecter les règles de collaboration d'Arthur :
|
||||
- Pas de Next.js, pas de Vercel
|
||||
- L'IA ne mute jamais la DB sans clic de confirmation
|
||||
- Design = portfolio arthurbarre.fr (pas le violet/cyan du HTML jobs)
|
||||
4. Avant de scaffolder, récupérer de l'utilisateur :
|
||||
- Le dossier `/Users/arthurbarre/dev/perso/proxmox` (conf k3s) pour aligner les manifests
|
||||
- Le skill `/deploy` ou `/create-deployment` qu'Arthur utilise pour ses autres déploiements Gitea
|
||||
- Les secrets nécessaires : `MISTRAL_API_KEY`, `GROQ_API_KEY`, `TELEGRAM_BOT_TOKEN`, `GOOGLE_OAUTH_CLIENT_ID/SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `API_BEARER_TOKEN`
|
||||
- Le choix du stockage backup S3-compatible (B2 / Scaleway / autre)
|
||||
5. Attaquer par la Phase 0 (scaffold), puis Phase 1 (jobs) — c'est explicitement prioritaire dans la tête d'Arthur.
|
||||
|
||||
## Points ouverts à trancher avec Arthur
|
||||
|
||||
- Stockage backups (quel bucket S3-compatible ?)
|
||||
- Gitea Container Registry vs GHCR (défaut proposé : Gitea CR, déjà présent dans sa stack)
|
||||
- STT Groq → clé à créer côté Arthur
|
||||
- Google OAuth → app Google Cloud à créer, redirect URI `https://api.os.arthurbarre.fr/agenda/google/oauth/callback`
|
||||
- Bot Telegram → @BotFather → récupérer le token, configurer le webhook vers `https://api.os.arthurbarre.fr/telegram/webhook`
|
||||
14
README.md
Normal file
14
README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# ordinarthur-os
|
||||
|
||||
Assistant personnel self-hosted d'Arthur Barré. PWA installable + backend NestJS, déployés sur k3s personnel, branchés sur Supabase self-hosted (`supabase.arthurbarre.fr`).
|
||||
|
||||
**But** : aider Arthur à être rigoureux (todos, projets, agenda, recherche d'emploi, santé) **sans le déresponsabiliser**. Toutes les actions IA passent par une confirmation explicite.
|
||||
|
||||
## Documents
|
||||
|
||||
- [`PLAN.md`](./PLAN.md) — roadmap, phases, ordre des tâches.
|
||||
- [`ARCHITECTURE.md`](./ARCHITECTURE.md) — stack, schéma Postgres, routes API, PWA, design system, déploiement.
|
||||
|
||||
## Handoff
|
||||
|
||||
Ces docs ont été produits en session de planning. L'implémentation est ensuite reprise par Claude Code (Sonnet) sur la machine locale d'Arthur. Voir la section "Handoff Claude Code" dans `PLAN.md`.
|
||||
26
apps/api/.env.example
Normal file
26
apps/api/.env.example
Normal file
@ -0,0 +1,26 @@
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# Single-user bearer (génère via `openssl rand -hex 32`)
|
||||
API_BEARER_TOKEN=
|
||||
|
||||
# Supabase self-hosted
|
||||
SUPABASE_URL=https://supabase.arthurbarre.fr
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_SCHEMA=ordinarthur_os
|
||||
|
||||
# Phase 5+
|
||||
MISTRAL_API_KEY=
|
||||
MISTRAL_MODEL=mistral-small-latest
|
||||
GROQ_API_KEY=
|
||||
GROQ_STT_MODEL=whisper-large-v3-turbo
|
||||
|
||||
# Phase 4
|
||||
GOOGLE_OAUTH_CLIENT_ID=
|
||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||
GOOGLE_OAUTH_REDIRECT_URI=https://api.os.arthurbarre.fr/agenda/google/oauth/callback
|
||||
ICAL_FEED_SECRET=
|
||||
|
||||
# Phase 6
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
24
apps/api/Dockerfile
Normal file
24
apps/api/Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
# Multi-stage build : build avec pnpm puis runtime minimal
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN pnpm --filter @ordinarthur-os/api build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable
|
||||
COPY --from=build /repo/apps/api/dist ./dist
|
||||
COPY --from=build /repo/apps/api/package.json ./package.json
|
||||
COPY --from=build /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/packages ./packages
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
30
apps/api/package.json
Normal file
30
apps/api/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@ordinarthur-os/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
"@nestjs/platform-express": "^10.4.4",
|
||||
"@ordinarthur-os/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.45.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.16.10",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
23
apps/api/src/app.module.ts
Normal file
23
apps/api/src/app.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Module, MiddlewareConsumer, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { DbModule } from "./db/db.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { AuthModule } from "./modules/auth/auth.module";
|
||||
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(BearerMiddleware)
|
||||
.exclude(
|
||||
{ path: "health", method: RequestMethod.GET },
|
||||
// Endpoints publics (signés autrement) ajoutés en Phase 4/6 :
|
||||
// { path: "telegram/webhook", method: RequestMethod.POST },
|
||||
// { path: "agenda/ical/:secret.ics", method: RequestMethod.GET },
|
||||
)
|
||||
.forRoutes("*");
|
||||
}
|
||||
}
|
||||
16
apps/api/src/config/config.module.ts
Normal file
16
apps/api/src/config/config.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { loadConfig } from "./env";
|
||||
|
||||
export const APP_CONFIG = Symbol("APP_CONFIG");
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
useFactory: () => loadConfig(),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
42
apps/api/src/config/env.ts
Normal file
42
apps/api/src/config/env.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
|
||||
API_BEARER_TOKEN: z.string().min(16, "API_BEARER_TOKEN must be at least 16 chars"),
|
||||
|
||||
SUPABASE_URL: z.string().url(),
|
||||
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
|
||||
SUPABASE_SCHEMA: z.string().default("ordinarthur_os"),
|
||||
|
||||
// Phase 5+ — optionnels jusque-là
|
||||
MISTRAL_API_KEY: z.string().optional(),
|
||||
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
||||
GROQ_API_KEY: z.string().optional(),
|
||||
GROQ_STT_MODEL: z.string().default("whisper-large-v3-turbo"),
|
||||
|
||||
GOOGLE_OAUTH_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_OAUTH_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_OAUTH_REDIRECT_URI: z.string().url().optional(),
|
||||
ICAL_FEED_SECRET: z.string().optional(),
|
||||
|
||||
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
||||
TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof EnvSchema>;
|
||||
|
||||
let cached: AppConfig | null = null;
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
if (cached) return cached;
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
||||
throw new Error("Invalid environment configuration");
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
27
apps/api/src/db/db.module.ts
Normal file
27
apps/api/src/db/db.module.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Global, Module, Inject } from "@nestjs/common";
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
||||
import { APP_CONFIG } from "../config/config.module";
|
||||
import type { AppConfig } from "../config/env";
|
||||
|
||||
export const SUPABASE = Symbol("SUPABASE");
|
||||
export type Supabase = SupabaseClient;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: SUPABASE,
|
||||
inject: [APP_CONFIG],
|
||||
useFactory: (config: AppConfig): SupabaseClient =>
|
||||
createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
db: { schema: config.SUPABASE_SCHEMA },
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [SUPABASE],
|
||||
})
|
||||
export class DbModule {}
|
||||
|
||||
// Convenience decorator : `constructor(@InjectSupabase() private db: Supabase) {}`
|
||||
export const InjectSupabase = () => Inject(SUPABASE);
|
||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loadConfig } from "./config/env";
|
||||
|
||||
async function bootstrap() {
|
||||
const config = loadConfig();
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ["log", "warn", "error"],
|
||||
});
|
||||
app.enableCors({ origin: true, credentials: false });
|
||||
await app.listen(config.PORT);
|
||||
Logger.log(`ordinarthur-os api ready on :${config.PORT}`, "Bootstrap");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
14
apps/api/src/modules/auth/auth.controller.ts
Normal file
14
apps/api/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Controller, Post, HttpCode } from "@nestjs/common";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
/**
|
||||
* Le bearer middleware tourne déjà avant cette route.
|
||||
* Si on arrive ici, le token est valide.
|
||||
*/
|
||||
@Post("verify")
|
||||
@HttpCode(200)
|
||||
verify() {
|
||||
return { ok: true as const };
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/auth/auth.module.ts
Normal file
10
apps/api/src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BearerMiddleware } from "./bearer.middleware";
|
||||
import { AuthController } from "./auth.controller";
|
||||
|
||||
@Module({
|
||||
providers: [BearerMiddleware],
|
||||
controllers: [AuthController],
|
||||
exports: [BearerMiddleware],
|
||||
})
|
||||
export class AuthModule {}
|
||||
21
apps/api/src/modules/auth/bearer.middleware.ts
Normal file
21
apps/api/src/modules/auth/bearer.middleware.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { APP_CONFIG } from "../../config/config.module";
|
||||
import type { AppConfig } from "../../config/env";
|
||||
|
||||
@Injectable()
|
||||
export class BearerMiddleware implements NestMiddleware {
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {}
|
||||
|
||||
use(req: Request, _res: Response, next: NextFunction) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Missing bearer token");
|
||||
}
|
||||
const token = header.slice("Bearer ".length).trim();
|
||||
if (token !== this.config.API_BEARER_TOKEN) {
|
||||
throw new UnauthorizedException("Invalid bearer token");
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
15
apps/api/src/modules/health/health.controller.ts
Normal file
15
apps/api/src/modules/health/health.controller.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
private readonly bootedAt = Date.now();
|
||||
|
||||
@Get()
|
||||
health() {
|
||||
return {
|
||||
ok: true as const,
|
||||
version: process.env.npm_package_version ?? "0.0.0",
|
||||
uptime: Math.floor((Date.now() - this.bootedAt) / 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/modules/health/health.module.ts
Normal file
7
apps/api/src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
15
apps/api/tsconfig.json
Normal file
15
apps/api/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"verbatimModuleSyntax": false,
|
||||
"isolatedModules": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/pwa/.env.example
Normal file
1
apps/pwa/.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
15
apps/pwa/Dockerfile
Normal file
15
apps/pwa/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM node:20-alpine AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
COPY apps/pwa/package.json apps/pwa/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
COPY . .
|
||||
RUN pnpm --filter @ordinarthur-os/pwa build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html
|
||||
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
16
apps/pwa/index.html
Normal file
16
apps/pwa/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#F5F1EA" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<title>ordinarthur-os</title>
|
||||
</head>
|
||||
<body class="bg-bg text-ink font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/pwa/nginx.conf
Normal file
25
apps/pwa/nginx.conf
Normal file
@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Service worker : jamais en cache long
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
location = /manifest.webmanifest {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Assets immuables (Vite hash dans le nom)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
34
apps/pwa/package.json
Normal file
34
apps/pwa/package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@ordinarthur-os/pwa",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview --port 5173 --host",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ordinarthur-os/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tanstack/react-router": "^1.58.7",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-vite-plugin": "^1.58.7",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-pwa": "^0.20.5"
|
||||
}
|
||||
}
|
||||
6
apps/pwa/postcss.config.cjs
Normal file
6
apps/pwa/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
apps/pwa/public/.gitkeep
Normal file
0
apps/pwa/public/.gitkeep
Normal file
10
apps/pwa/public/icons/README.md
Normal file
10
apps/pwa/public/icons/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Icons à fournir
|
||||
|
||||
Placer ici (formats PNG) :
|
||||
|
||||
- `icon-192.png` — 192×192
|
||||
- `icon-512.png` — 512×512
|
||||
- `icon-maskable.png` — 512×512, safe area centrée
|
||||
- `apple-touch-icon.png` — 180×180
|
||||
|
||||
Référencés depuis `vite.config.ts` (manifest PWA) et `index.html`.
|
||||
47
apps/pwa/src/api/client.ts
Normal file
47
apps/pwa/src/api/client.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Client HTTP minimal vers le BFF NestJS.
|
||||
* Bearer token stocké en localStorage (saisi par Arthur sur l'écran d'onboarding).
|
||||
*/
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
const TOKEN_KEY = "ordinarthur.bearer";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
init: RequestInit & { auth?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const { auth = true, headers, ...rest } = init;
|
||||
const finalHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(headers as Record<string, string> | undefined),
|
||||
};
|
||||
if (auth) {
|
||||
const token = getToken();
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, { ...rest, headers: finalHeaders });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
13
apps/pwa/src/design/AccentDot.tsx
Normal file
13
apps/pwa/src/design/AccentDot.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
/** Point orange qui pulse — marqueur "live/disponible". */
|
||||
export function AccentDot({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full bg-accent animate-accent-pulse",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
apps/pwa/src/design/BigHeading.tsx
Normal file
25
apps/pwa/src/design/BigHeading.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface BigHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
||||
as?: "h1" | "h2";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gros titre éditorial. Italique = orange (convention portfolio).
|
||||
* Wrap n'importe quel texte en <em> pour qu'il passe en accent.
|
||||
*/
|
||||
export function BigHeading({ as: Tag = "h1", className, children, ...props }: BigHeadingProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={cn(
|
||||
"font-sans font-light tracking-tightest text-ink",
|
||||
"text-[clamp(2.5rem,8vw,6rem)] leading-[0.95]",
|
||||
"[&_em]:not-italic [&_em]:text-accent [&_em]:italic",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
25
apps/pwa/src/design/DataChip.tsx
Normal file
25
apps/pwa/src/design/DataChip.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface DataChipProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
dotColor?: "accent" | "ink";
|
||||
}
|
||||
|
||||
export function DataChip({ dotColor = "ink", className, children, ...props }: DataChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 border border-ink px-2 py-1 font-mono text-[11px] uppercase tracking-label",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
dotColor === "accent" ? "bg-accent" : "bg-ink",
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
20
apps/pwa/src/design/GridFrame.tsx
Normal file
20
apps/pwa/src/design/GridFrame.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface GridFrameProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export function GridFrame({ cols = 12, className, children, ...props }: GridFrameProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid border border-ink divide-x divide-ink",
|
||||
className,
|
||||
)}
|
||||
style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
apps/pwa/src/design/Label.tsx
Normal file
24
apps/pwa/src/design/Label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface LabelProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mini-label éditorial : mono, uppercase, 11px, tracking-label, muted.
|
||||
* Ex: <Label prefix="[ 01 ]">À PROPOS</Label>
|
||||
*/
|
||||
export function Label({ prefix, className, children, ...props }: LabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11px] uppercase tracking-label text-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{prefix ? <span className="mr-2 text-ink">{prefix}</span> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
17
apps/pwa/src/design/MetaRow.tsx
Normal file
17
apps/pwa/src/design/MetaRow.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "./Label";
|
||||
|
||||
interface MetaRowProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetaRow({ label, value, className }: MetaRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-6 border-b border-ink py-3", className)}>
|
||||
<Label className="w-32 shrink-0">{label}</Label>
|
||||
<div className="flex-1 font-sans text-sm text-ink">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/pwa/src/design/SectionHeader.tsx
Normal file
29
apps/pwa/src/design/SectionHeader.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Label } from "./Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
number: string;
|
||||
label: string;
|
||||
title: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({ number, label, title, className }: SectionHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"border border-ink divide-y divide-ink",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Label prefix={`[ ${number} ]`}>{label}</Label>
|
||||
</div>
|
||||
<div className="px-4 py-6">
|
||||
<h2 className="font-sans font-light tracking-tightest text-3xl md:text-5xl text-ink">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
7
apps/pwa/src/design/index.ts
Normal file
7
apps/pwa/src/design/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { Label } from "./Label";
|
||||
export { SectionHeader } from "./SectionHeader";
|
||||
export { GridFrame } from "./GridFrame";
|
||||
export { DataChip } from "./DataChip";
|
||||
export { MetaRow } from "./MetaRow";
|
||||
export { BigHeading } from "./BigHeading";
|
||||
export { AccentDot } from "./AccentDot";
|
||||
6
apps/pwa/src/lib/cn.ts
Normal file
6
apps/pwa/src/lib/cn.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import clsx, { type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
26
apps/pwa/src/main.tsx
Normal file
26
apps/pwa/src/main.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
|
||||
});
|
||||
|
||||
const router = createRouter({ routeTree, defaultPreload: "intent" });
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
26
apps/pwa/src/routeTree.gen.ts
Normal file
26
apps/pwa/src/routeTree.gen.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/* prettier-ignore-start */
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
// Ce fichier est régénéré par @tanstack/router-vite-plugin au premier `pnpm dev`.
|
||||
// On commit un stub minimal pour que `tsc --noEmit` passe avant le premier dev run.
|
||||
|
||||
import { Route as rootRoute } from "./routes/__root";
|
||||
import { Route as IndexRoute } from "./routes/index";
|
||||
|
||||
const IndexRouteWithParent = IndexRoute.update({
|
||||
path: "/",
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any);
|
||||
|
||||
export const routeTree = rootRoute.addChildren({
|
||||
IndexRoute: IndexRouteWithParent,
|
||||
} as any);
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/": { parentRoute: typeof rootRoute };
|
||||
}
|
||||
}
|
||||
|
||||
/* prettier-ignore-end */
|
||||
46
apps/pwa/src/routes/__root.tsx
Normal file
46
apps/pwa/src/routes/__root.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Outlet, createRootRoute, Link } from "@tanstack/react-router";
|
||||
import { AccentDot, Label } from "@/design";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-ink">
|
||||
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<AccentDot />
|
||||
<Label className="text-ink">ORDINARTHUR-OS</Label>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
{/* Routes activées au fur et à mesure des phases */}
|
||||
<NavLink to="/">Dashboard</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 mx-auto max-w-7xl w-full px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t border-ink">
|
||||
<div className="mx-auto max-w-7xl flex items-center justify-between px-4 py-3">
|
||||
<Label>v0.0.0 · phase 0</Label>
|
||||
<Label>arthurbarre.fr</Label>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="font-mono text-[11px] uppercase tracking-label text-muted hover:text-ink [&.active]:text-ink"
|
||||
activeProps={{ className: "active" }}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
72
apps/pwa/src/routes/index.tsx
Normal file
72
apps/pwa/src/routes/index.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { BigHeading, DataChip, GridFrame, Label, MetaRow, SectionHeader } from "@/design";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: Dashboard });
|
||||
|
||||
function Dashboard() {
|
||||
const health = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => api<{ ok: true; version: string; uptime: number }>("/health", { auth: false }),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<Label prefix="[ 00 ]">PHASE 0 · SCAFFOLD</Label>
|
||||
<BigHeading className="mt-4">
|
||||
Un assistant <em>qui n'agit jamais</em> sans ton clic.
|
||||
</BigHeading>
|
||||
</section>
|
||||
|
||||
<SectionHeader
|
||||
number="01"
|
||||
label="STATUS"
|
||||
title="Backend handshake"
|
||||
/>
|
||||
|
||||
<GridFrame cols={2}>
|
||||
<div className="p-6 space-y-3">
|
||||
<Label>API HEALTH</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<DataChip dotColor={health.isSuccess ? "accent" : "ink"}>
|
||||
{health.isLoading ? "CHECK..." : health.isSuccess ? "OK" : "DOWN"}
|
||||
</DataChip>
|
||||
{health.data && (
|
||||
<span className="font-mono text-[11px] text-muted">
|
||||
v{health.data.version} · uptime {health.data.uptime}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
<Label>STACK</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DataChip>VITE · REACT</DataChip>
|
||||
<DataChip>NESTJS</DataChip>
|
||||
<DataChip>SUPABASE</DataChip>
|
||||
<DataChip>K3S</DataChip>
|
||||
</div>
|
||||
</div>
|
||||
</GridFrame>
|
||||
|
||||
<section className="border border-ink">
|
||||
<div className="px-4 py-3 border-b border-ink">
|
||||
<Label prefix="[ 02 ]">ROADMAP</Label>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<MetaRow label="PHASE 0" value="Scaffold (en cours)" />
|
||||
<MetaRow label="PHASE 1" value="Jobs · prio remontée" />
|
||||
<MetaRow label="PHASE 2" value="Todos riches" />
|
||||
<MetaRow label="PHASE 3" value="Projets + Kanban" />
|
||||
<MetaRow label="PHASE 4" value="Agenda + Google Calendar" />
|
||||
<MetaRow label="PHASE 5" value="IA texte + voice magic button" />
|
||||
<MetaRow label="PHASE 6" value="Telegram bot" />
|
||||
<MetaRow label="PHASE 7" value="Health tab" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/pwa/src/styles.css
Normal file
16
apps/pwa/src/styles.css
Normal file
@ -0,0 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Polices : à servir self-hosted plus tard. Fallback system stack pour l'instant. */
|
||||
|
||||
@layer base {
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background-color: theme(colors.bg);
|
||||
color: theme(colors.ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
*::selection { background: theme(colors.accent); color: theme(colors.bg); }
|
||||
}
|
||||
34
apps/pwa/tailwind.config.ts
Normal file
34
apps/pwa/tailwind.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: "#F5F1EA",
|
||||
ink: "#0F0F0F",
|
||||
muted: "#8A8579",
|
||||
accent: "#FF4A1C",
|
||||
surface: "#FFFFFF",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['"Inter Tight"', "system-ui", "sans-serif"],
|
||||
mono: ['"Space Mono"', '"JetBrains Mono"', "monospace"],
|
||||
},
|
||||
letterSpacing: {
|
||||
tightest: "-0.03em",
|
||||
label: "0.06em",
|
||||
},
|
||||
keyframes: {
|
||||
"accent-pulse": {
|
||||
"0%, 100%": { opacity: "1" },
|
||||
"50%": { opacity: "0.35" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accent-pulse": "accent-pulse 1.6s ease-in-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
16
apps/pwa/tsconfig.json
Normal file
16
apps/pwa/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
38
apps/pwa/vite.config.ts
Normal file
38
apps/pwa/vite.config.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.ico", "icons/*.png"],
|
||||
manifest: {
|
||||
name: "ordinarthur-os",
|
||||
short_name: "ordinarthur",
|
||||
description: "Assistant personnel d'Arthur Barré",
|
||||
theme_color: "#F5F1EA",
|
||||
background_color: "#F5F1EA",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/icons/icon-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// Phase 2 : ajouter stale-while-revalidate sur GET /api/*
|
||||
navigateFallback: "/index.html",
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "src") },
|
||||
},
|
||||
server: { port: 5173, host: true },
|
||||
});
|
||||
24
deploy/k8s/README.md
Normal file
24
deploy/k8s/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# deploy/k8s
|
||||
|
||||
Manifests Kubernetes pour le k3s perso d'Arthur.
|
||||
|
||||
## Ordre d'application initial
|
||||
|
||||
```bash
|
||||
kubectl apply -f namespace.yaml
|
||||
# Copier secrets.template.yaml -> secrets.yaml, remplir, puis :
|
||||
kubectl apply -f secrets.yaml
|
||||
kubectl apply -f api.deployment.yaml
|
||||
kubectl apply -f pwa.deployment.yaml
|
||||
kubectl apply -f ingress.yaml
|
||||
# Une fois le bucket S3 choisi :
|
||||
kubectl apply -f backup.cronjob.yaml
|
||||
```
|
||||
|
||||
## Points à confirmer avec Arthur avant déploiement réel
|
||||
|
||||
- Cluster issuer cert-manager (`letsencrypt-prod` ?)
|
||||
- Entrée Traefik (`websecure` ?)
|
||||
- DNS : `os.arthurbarre.fr` et `api.os.arthurbarre.fr` doivent pointer sur l'IP du load-balancer k3s
|
||||
- Bucket S3-compatible pour les backups (B2 / Scaleway / autre)
|
||||
- Image registry : Gitea CR (défaut) — credentials pull peuvent nécessiter un `imagePullSecrets`
|
||||
41
deploy/k8s/api.deployment.yaml
Normal file
41
deploy/k8s/api.deployment.yaml
Normal file
@ -0,0 +1,41 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: { matchLabels: { app: api } }
|
||||
template:
|
||||
metadata: { labels: { app: api } }
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports: [{ containerPort: 3000 }]
|
||||
envFrom:
|
||||
- secretRef: { name: ordinarthur-os-secrets }
|
||||
env:
|
||||
- { name: NODE_ENV, value: production }
|
||||
- { name: PORT, value: "3000" }
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests: { cpu: 50m, memory: 128Mi }
|
||||
limits: { cpu: 500m, memory: 512Mi }
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: api
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
selector: { app: api }
|
||||
ports: [{ port: 3000, targetPort: 3000 }]
|
||||
29
deploy/k8s/backup.cronjob.yaml
Normal file
29
deploy/k8s/backup.cronjob.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# Backup quotidien du schéma ordinarthur_os.
|
||||
# `PGURL` et `RCLONE_REMOTE` à fournir via secret séparé `ordinarthur-os-backup-secrets`
|
||||
# (voir secrets.template.yaml — à splitter quand le bucket S3 est choisi avec Arthur).
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: ordinarthur-os-backup
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
schedule: "0 3 * * *"
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 3
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: pgdump
|
||||
image: postgres:16-alpine
|
||||
envFrom:
|
||||
- secretRef: { name: ordinarthur-os-backup-secrets }
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -euo pipefail
|
||||
apk add --no-cache rclone
|
||||
pg_dump "$PGURL" --schema=ordinarthur_os --format=c | gzip > /tmp/dump.gz
|
||||
rclone copy /tmp/dump.gz "$RCLONE_REMOTE/$(date +%F).gz"
|
||||
27
deploy/k8s/ingress.yaml
Normal file
27
deploy/k8s/ingress.yaml
Normal file
@ -0,0 +1,27 @@
|
||||
# À aligner sur la conf Traefik / cert-manager d'Arthur (cf. /Users/arthurbarre/dev/perso/proxmox).
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ordinarthur-os
|
||||
namespace: ordinarthur-os
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts: [os.arthurbarre.fr, api.os.arthurbarre.fr]
|
||||
secretName: ordinarthur-os-tls
|
||||
rules:
|
||||
- host: os.arthurbarre.fr
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend: { service: { name: pwa, port: { number: 80 } } }
|
||||
- host: api.os.arthurbarre.fr
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend: { service: { name: api, port: { number: 3000 } } }
|
||||
4
deploy/k8s/namespace.yaml
Normal file
4
deploy/k8s/namespace.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: ordinarthur-os
|
||||
31
deploy/k8s/pwa.deployment.yaml
Normal file
31
deploy/k8s/pwa.deployment.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: pwa
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: { matchLabels: { app: pwa } }
|
||||
template:
|
||||
metadata: { labels: { app: pwa } }
|
||||
spec:
|
||||
containers:
|
||||
- name: pwa
|
||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
|
||||
imagePullPolicy: Always
|
||||
ports: [{ containerPort: 80 }]
|
||||
readinessProbe:
|
||||
httpGet: { path: /, port: 80 }
|
||||
initialDelaySeconds: 3
|
||||
resources:
|
||||
requests: { cpu: 20m, memory: 32Mi }
|
||||
limits: { cpu: 100m, memory: 128Mi }
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: pwa
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
selector: { app: pwa }
|
||||
ports: [{ port: 80, targetPort: 80 }]
|
||||
24
deploy/k8s/secrets.template.yaml
Normal file
24
deploy/k8s/secrets.template.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
# NE PAS COMMITER LES VRAIES VALEURS.
|
||||
# Ce fichier est un template — appliquer une copie remplie via :
|
||||
# kubectl -n ordinarthur-os apply -f secrets.yaml
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ordinarthur-os-secrets
|
||||
namespace: ordinarthur-os
|
||||
type: Opaque
|
||||
stringData:
|
||||
API_BEARER_TOKEN: ""
|
||||
SUPABASE_URL: "https://supabase.arthurbarre.fr"
|
||||
SUPABASE_SERVICE_ROLE_KEY: ""
|
||||
SUPABASE_SCHEMA: "ordinarthur_os"
|
||||
MISTRAL_API_KEY: ""
|
||||
MISTRAL_MODEL: "mistral-small-latest"
|
||||
GROQ_API_KEY: ""
|
||||
GROQ_STT_MODEL: "whisper-large-v3-turbo"
|
||||
GOOGLE_OAUTH_CLIENT_ID: ""
|
||||
GOOGLE_OAUTH_CLIENT_SECRET: ""
|
||||
GOOGLE_OAUTH_REDIRECT_URI: "https://api.os.arthurbarre.fr/agenda/google/oauth/callback"
|
||||
ICAL_FEED_SECRET: ""
|
||||
TELEGRAM_BOT_TOKEN: ""
|
||||
TELEGRAM_WEBHOOK_SECRET: ""
|
||||
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "ordinarthur-os",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.1.3",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
}
|
||||
}
|
||||
15
packages/db/README.md
Normal file
15
packages/db/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# @ordinarthur-os/db
|
||||
|
||||
Migrations SQL versionnées pour le schéma `ordinarthur_os` sur Supabase self-hosted.
|
||||
|
||||
Pas d'outil de migration automatique pour l'instant : appliquer manuellement via le SQL editor de Supabase, dans l'ordre numérique. Chaque fichier doit être idempotent autant que possible (`create … if not exists`).
|
||||
|
||||
## Ordre
|
||||
|
||||
- `0001_schema.sql` — création du schéma + RLS service-role-only (Phase 0)
|
||||
- `0002_jobs.sql` — tables `jobs`, `job_search_criteria` (Phase 1)
|
||||
- `0003_todos.sql` — table `todos` + `client_mutations` (Phase 2)
|
||||
- `0004_projects.sql` — `projects`, `project_steps`, `project_ideas` (Phase 3)
|
||||
- `0005_agenda.sql` — `calendar_events`, `google_oauth_tokens` (Phase 4)
|
||||
- `0006_ai.sql` — `ai_actions` (Phase 5)
|
||||
- `0007_health.sql` — `daily_checkins` (Phase 7)
|
||||
20
packages/db/migrations/0001_schema.sql
Normal file
20
packages/db/migrations/0001_schema.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- 0001_schema.sql — Phase 0
|
||||
-- Crée le schéma dédié `ordinarthur_os`. Les tables métier arrivent dans
|
||||
-- les migrations suivantes (0002+). On pose ici uniquement le socle.
|
||||
|
||||
create schema if not exists ordinarthur_os;
|
||||
|
||||
-- pgcrypto pour gen_random_uuid()
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
comment on schema ordinarthur_os is
|
||||
'Assistant personnel single-user d''Arthur. Accès via service_role uniquement.';
|
||||
|
||||
-- Helper appliqué à chaque table créée dans les migrations suivantes :
|
||||
--
|
||||
-- alter table ordinarthur_os.<t> enable row level security;
|
||||
-- create policy "<t>_service_role" on ordinarthur_os.<t>
|
||||
-- for all using (auth.role() = 'service_role')
|
||||
-- with check (auth.role() = 'service_role');
|
||||
--
|
||||
-- (Repris explicitement dans chaque migration de table.)
|
||||
8
packages/db/package.json
Normal file
8
packages/db/package.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@ordinarthur-os/db",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"migrate:print": "ls migrations"
|
||||
}
|
||||
}
|
||||
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@ordinarthur-os/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
74
packages/shared/src/index.ts
Normal file
74
packages/shared/src/index.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Common
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const HealthResponse = z.object({
|
||||
ok: z.literal(true),
|
||||
version: z.string(),
|
||||
uptime: z.number(),
|
||||
});
|
||||
export type HealthResponse = z.infer<typeof HealthResponse>;
|
||||
|
||||
export const AuthVerifyResponse = z.object({
|
||||
ok: z.literal(true),
|
||||
});
|
||||
export type AuthVerifyResponse = z.infer<typeof AuthVerifyResponse>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 placeholders — fully fleshed out in Phase 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RemoteType = z.enum(["remote", "hybrid", "onsite"]);
|
||||
export type RemoteType = z.infer<typeof RemoteType>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ProposedAction = z.discriminatedUnion("fn", [
|
||||
z.object({
|
||||
fn: z.literal("create_todo"),
|
||||
args: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
due_at: z.string().datetime().optional(),
|
||||
priority: z.number().int().min(0).max(3).optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
fn: z.literal("add_project_idea"),
|
||||
args: z.object({
|
||||
project_id: z.string().uuid(),
|
||||
content: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
fn: z.literal("add_project_step"),
|
||||
args: z.object({
|
||||
project_id: z.string().uuid(),
|
||||
title: z.string(),
|
||||
status: z.enum(["backlog", "todo", "doing", "review", "done"]).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
fn: z.literal("create_calendar_event"),
|
||||
args: z.object({
|
||||
title: z.string(),
|
||||
starts_at: z.string().datetime(),
|
||||
ends_at: z.string().datetime(),
|
||||
location: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
fn: z.literal("toggle_daily_checkin"),
|
||||
args: z.object({
|
||||
note: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
export type ProposedAction = z.infer<typeof ProposedAction>;
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "preserve"
|
||||
}
|
||||
}
|
||||
17
turbo.json
Normal file
17
turbo.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".vite/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user