add project
This commit is contained in:
parent
7de7ef16b9
commit
6fdfbab996
@ -13,7 +13,38 @@
|
||||
"Bash(pnpm --filter @ordinarthur-os/pwa typecheck)",
|
||||
"Bash(pnpm -r typecheck)",
|
||||
"Bash(pnpm --filter pwa build)",
|
||||
"Bash(pnpm --filter api 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,45 @@
|
||||
# Squelette à aligner sur le skill /deploy d'Arthur.
|
||||
# - Build images api + pwa
|
||||
# - Push vers Gitea Container Registry
|
||||
# - kubectl set image (ou apply via kustomize plus tard)
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with: { version: 9 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 20, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -r build
|
||||
|
||||
- name: Set image tags
|
||||
run: |
|
||||
SHA=$(echo "${{ github.sha }}" | cut -c1-8)
|
||||
echo "API_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:$SHA" >> $GITHUB_ENV
|
||||
echo "PWA_TAG=git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:$SHA" >> $GITHUB_ENV
|
||||
|
||||
- name: Login Gitea Container Registry
|
||||
run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.arthurbarre.fr -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build & push images
|
||||
run: |
|
||||
API_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:${{ github.sha }}
|
||||
PWA_TAG=gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:${{ github.sha }}
|
||||
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
||||
docker build -f apps/pwa/Dockerfile -t "$PWA_TAG" .
|
||||
docker push "$API_TAG"
|
||||
docker push "$PWA_TAG"
|
||||
echo "API_TAG=$API_TAG" >> $GITHUB_ENV
|
||||
echo "PWA_TAG=$PWA_TAG" >> $GITHUB_ENV
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
|
||||
docker login git.arthurbarre.fr -u ordinarthur --password-stdin
|
||||
|
||||
- name: Deploy on k3s
|
||||
- name: Build & push API
|
||||
run: |
|
||||
docker build -f apps/api/Dockerfile -t "$API_TAG" .
|
||||
docker push "$API_TAG"
|
||||
|
||||
- name: Build & push PWA
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL=https://api.os.arthurbarre.fr \
|
||||
-f apps/pwa/Dockerfile \
|
||||
-t "$PWA_TAG" .
|
||||
docker push "$PWA_TAG"
|
||||
|
||||
- name: Deploy on K3s
|
||||
env:
|
||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
||||
run: |
|
||||
mkdir -p ~/.kube && echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config
|
||||
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
|
||||
|
||||
@ -1,24 +1,42 @@
|
||||
# Multi-stage build : build avec pnpm puis runtime minimal
|
||||
FROM node:20-alpine AS deps
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# package.json seuls d'abord (layer cache pnpm install)
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
FROM deps AS build
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Sources complètes
|
||||
COPY . .
|
||||
|
||||
# Build dans l'ordre des dépendances
|
||||
RUN pnpm --filter @ordinarthur-os/shared build
|
||||
RUN pnpm --filter @ordinarthur-os/db build
|
||||
RUN pnpm --filter @ordinarthur-os/api build
|
||||
|
||||
# Deploy propre : pnpm résout les workspace packages compilés vers /deploy/api
|
||||
RUN pnpm --filter @ordinarthur-os/api deploy --prod /deploy/api
|
||||
|
||||
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN corepack enable
|
||||
COPY --from=build /repo/apps/api/dist ./dist
|
||||
COPY --from=build /repo/apps/api/package.json ./package.json
|
||||
COPY --from=build /repo/node_modules ./node_modules
|
||||
COPY --from=build /repo/packages ./packages
|
||||
|
||||
# Deps de prod isolées (inclut @ordinarthur-os/shared+db compilés)
|
||||
COPY --from=builder /deploy/api/node_modules ./node_modules
|
||||
COPY --from=builder /deploy/api/package.json ./
|
||||
|
||||
# Code compilé de l'API
|
||||
COPY --from=builder /workspace/apps/api/dist ./dist
|
||||
|
||||
# Migrations SQL (pour le job K3s migrate)
|
||||
COPY --from=builder /workspace/packages/db/migrations ./packages/db/migrations
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@ -9,6 +9,7 @@ import { TodosModule } from "./modules/todos/todos.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { HealthTabModule } from "./modules/health-tab/health-tab.module";
|
||||
import { AgendaModule } from "./modules/agenda/agenda.module";
|
||||
import { ProjectsModule } from "./modules/projects/projects.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -21,6 +22,7 @@ import { AgendaModule } from "./modules/agenda/agenda.module";
|
||||
AiModule,
|
||||
HealthTabModule,
|
||||
AgendaModule,
|
||||
ProjectsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@ -3,8 +3,10 @@ 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],
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} 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";
|
||||
|
||||
@ -24,6 +25,7 @@ export class AiService {
|
||||
@InjectDb() private readonly db: Db,
|
||||
private readonly mistral: MistralClient,
|
||||
private readonly groq: GroqClient,
|
||||
private readonly projects: ProjectsService,
|
||||
) {}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@ -187,9 +189,28 @@ export class AiService {
|
||||
return { todo_id: row.id };
|
||||
}
|
||||
|
||||
case "add_project_idea":
|
||||
case "add_project_step":
|
||||
throw new Error(`Fonction "${action.fn}" pas encore câblée (phase 3)`);
|
||||
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
|
||||
|
||||
@ -104,6 +104,35 @@ export const MISTRAL_TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
@ -135,9 +164,19 @@ const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os.
|
||||
- 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.
|
||||
- Traite les formulations déclaratives comme des demandes d'action. "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. Si seule l'heure de début est donnée, calcule ends_at = starts_at + 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) en te basant sur l'heure courante fournie. Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`;
|
||||
|
||||
## 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 {
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@ -1,15 +1,28 @@
|
||||
FROM node:20-alpine AS build
|
||||
RUN corepack enable
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./
|
||||
# ── Stage 1 : build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
COPY apps/pwa/package.json apps/pwa/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
# L'URL de l'API est baked-in au build (Vite env var)
|
||||
ARG VITE_API_BASE_URL=https://api.os.arthurbarre.fr
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
RUN pnpm --filter @ordinarthur-os/pwa build
|
||||
|
||||
# ── Stage 2 : runtime ────────────────────────────────────────────────────────
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html
|
||||
|
||||
COPY --from=builder /workspace/apps/pwa/dist /usr/share/nginx/html
|
||||
COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@ -11,6 +11,9 @@
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@ordinarthur-os/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tanstack/react-router": "^1.58.7",
|
||||
|
||||
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;
|
||||
}
|
||||
@ -43,6 +43,8 @@ export function VoiceConfirmModal({ response, onClose }: Props) {
|
||||
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();
|
||||
},
|
||||
});
|
||||
@ -256,6 +258,7 @@ function ActionRow({
|
||||
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";
|
||||
@ -279,6 +282,8 @@ function summarize(action: ProposedAction): string {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ 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;
|
||||
|
||||
|
||||
@ -10,16 +10,24 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TodosRouteImport } from './routes/todos'
|
||||
import { Route as ProjectsRouteImport } from './routes/projects'
|
||||
import { Route as JobsRouteImport } from './routes/jobs'
|
||||
import { Route as AgendaRouteImport } from './routes/agenda'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ProjectsIndexRouteImport } from './routes/projects.index'
|
||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||
import { Route as ProjectsProjectIdRouteImport } from './routes/projects.$projectId'
|
||||
|
||||
const TodosRoute = TodosRouteImport.update({
|
||||
id: '/todos',
|
||||
path: '/todos',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProjectsRoute = ProjectsRouteImport.update({
|
||||
id: '/projects',
|
||||
path: '/projects',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const JobsRoute = JobsRouteImport.update({
|
||||
id: '/jobs',
|
||||
path: '/jobs',
|
||||
@ -35,46 +43,89 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProjectsIndexRoute = ProjectsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => ProjectsRoute,
|
||||
} as any)
|
||||
const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
||||
id: '/settings/jobs',
|
||||
path: '/settings/jobs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProjectsProjectIdRoute = ProjectsProjectIdRouteImport.update({
|
||||
id: '/$projectId',
|
||||
path: '/$projectId',
|
||||
getParentRoute: () => ProjectsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/agenda': typeof AgendaRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/projects': typeof ProjectsRouteWithChildren
|
||||
'/todos': typeof TodosRoute
|
||||
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
'/projects/': typeof ProjectsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/agenda': typeof AgendaRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
'/projects': typeof ProjectsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/agenda': typeof AgendaRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/projects': typeof ProjectsRouteWithChildren
|
||||
'/todos': typeof TodosRoute
|
||||
'/projects/$projectId': typeof ProjectsProjectIdRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
'/projects/': typeof ProjectsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agenda'
|
||||
| '/jobs'
|
||||
| '/projects'
|
||||
| '/todos'
|
||||
| '/projects/$projectId'
|
||||
| '/settings/jobs'
|
||||
| '/projects/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
id: '__root__' | '/' | '/agenda' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
to:
|
||||
| '/'
|
||||
| '/agenda'
|
||||
| '/jobs'
|
||||
| '/todos'
|
||||
| '/projects/$projectId'
|
||||
| '/settings/jobs'
|
||||
| '/projects'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/agenda'
|
||||
| '/jobs'
|
||||
| '/projects'
|
||||
| '/todos'
|
||||
| '/projects/$projectId'
|
||||
| '/settings/jobs'
|
||||
| '/projects/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AgendaRoute: typeof AgendaRoute
|
||||
JobsRoute: typeof JobsRoute
|
||||
ProjectsRoute: typeof ProjectsRouteWithChildren
|
||||
TodosRoute: typeof TodosRoute
|
||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||
}
|
||||
@ -88,6 +139,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof TodosRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/projects': {
|
||||
id: '/projects'
|
||||
path: '/projects'
|
||||
fullPath: '/projects'
|
||||
preLoaderRoute: typeof ProjectsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/jobs': {
|
||||
id: '/jobs'
|
||||
path: '/jobs'
|
||||
@ -109,6 +167,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/projects/': {
|
||||
id: '/projects/'
|
||||
path: '/'
|
||||
fullPath: '/projects/'
|
||||
preLoaderRoute: typeof ProjectsIndexRouteImport
|
||||
parentRoute: typeof ProjectsRoute
|
||||
}
|
||||
'/settings/jobs': {
|
||||
id: '/settings/jobs'
|
||||
path: '/settings/jobs'
|
||||
@ -116,13 +181,35 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsJobsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/projects/$projectId': {
|
||||
id: '/projects/$projectId'
|
||||
path: '/$projectId'
|
||||
fullPath: '/projects/$projectId'
|
||||
preLoaderRoute: typeof ProjectsProjectIdRouteImport
|
||||
parentRoute: typeof ProjectsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectsRouteChildren {
|
||||
ProjectsProjectIdRoute: typeof ProjectsProjectIdRoute
|
||||
ProjectsIndexRoute: typeof ProjectsIndexRoute
|
||||
}
|
||||
|
||||
const ProjectsRouteChildren: ProjectsRouteChildren = {
|
||||
ProjectsProjectIdRoute: ProjectsProjectIdRoute,
|
||||
ProjectsIndexRoute: ProjectsIndexRoute,
|
||||
}
|
||||
|
||||
const ProjectsRouteWithChildren = ProjectsRoute._addFileChildren(
|
||||
ProjectsRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AgendaRoute: AgendaRoute,
|
||||
JobsRoute: JobsRoute,
|
||||
ProjectsRoute: ProjectsRouteWithChildren,
|
||||
TodosRoute: TodosRoute,
|
||||
SettingsJobsRoute: SettingsJobsRoute,
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ function RootLayout() {
|
||||
<NavLink to="/">Dashboard</NavLink>
|
||||
<NavLink to="/agenda">Agenda</NavLink>
|
||||
<NavLink to="/todos">Todos</NavLink>
|
||||
<NavLink to="/projects">Projets</NavLink>
|
||||
<NavLink to="/jobs">Jobs</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
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 />,
|
||||
});
|
||||
@ -9,9 +9,11 @@ spec:
|
||||
template:
|
||||
metadata: { labels: { app: api } }
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: api
|
||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-api:latest
|
||||
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports: [{ containerPort: 3000 }]
|
||||
envFrom:
|
||||
@ -19,6 +21,7 @@ spec:
|
||||
env:
|
||||
- { name: NODE_ENV, value: production }
|
||||
- { name: PORT, value: "3000" }
|
||||
- { name: PWA_URL, value: "https://os.arthurbarre.fr" }
|
||||
readinessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 5
|
||||
@ -37,5 +40,9 @@ metadata:
|
||||
name: api
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
type: NodePort
|
||||
selector: { app: api }
|
||||
ports: [{ port: 3000, targetPort: 3000 }]
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
nodePort: 30100
|
||||
|
||||
@ -1,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 } } }
|
||||
@ -1,13 +1,10 @@
|
||||
# Job one-shot qui applique les migrations Drizzle.
|
||||
# À rejouer manuellement après chaque déploiement qui contient une migration :
|
||||
# kubectl -n ordinarthur-os delete job ordinarthur-os-migrate --ignore-not-found
|
||||
# kubectl -n ordinarthur-os apply -f migrate.job.yaml
|
||||
#
|
||||
# (Peut aussi être branché dans le pipeline Gitea pour être auto-déclenché.)
|
||||
# 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: ordinarthur-os-migrate
|
||||
name: migrate
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
backoffLimit: 2
|
||||
@ -15,13 +12,26 @@ spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: migrate
|
||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-migrate:latest
|
||||
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-api:latest
|
||||
imagePullPolicy: Always
|
||||
envFrom:
|
||||
- secretRef: { name: ordinarthur-os-secrets }
|
||||
command: ["node", "dist/migrate.js"]
|
||||
# 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 }
|
||||
|
||||
@ -9,14 +9,17 @@ spec:
|
||||
template:
|
||||
metadata: { labels: { app: pwa } }
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: pwa
|
||||
image: gitea.arthurbarre.fr/arthurbarre/ordinarthur-os-pwa:latest
|
||||
image: git.arthurbarre.fr/ordinarthur/ordinarthur-os-pwa:latest
|
||||
imagePullPolicy: Always
|
||||
ports: [{ containerPort: 80 }]
|
||||
readinessProbe:
|
||||
httpGet: { path: /, port: 80 }
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests: { cpu: 20m, memory: 32Mi }
|
||||
limits: { cpu: 100m, memory: 128Mi }
|
||||
@ -27,5 +30,9 @@ metadata:
|
||||
name: pwa
|
||||
namespace: ordinarthur-os
|
||||
spec:
|
||||
type: NodePort
|
||||
selector: { app: pwa }
|
||||
ports: [{ port: 80, targetPort: 80 }]
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
nodePort: 30099
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# NE PAS COMMITER LES VRAIES VALEURS.
|
||||
# Deux Secrets sont attendus côté cluster — dupliquer, remplir, puis :
|
||||
# Dupliquer, remplir, puis :
|
||||
# kubectl -n ordinarthur-os apply -f secrets.yaml
|
||||
---
|
||||
apiVersion: v1
|
||||
@ -10,9 +10,7 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
API_BEARER_TOKEN: ""
|
||||
# Postgres standalone dans le cluster (cf. postgres.yaml).
|
||||
# Format : postgres://<user>:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/<db>
|
||||
DATABASE_URL: ""
|
||||
DATABASE_URL: "postgres://ordinarthur:<password>@postgres.ordinarthur-os.svc.cluster.local:5432/ordinarthur_os"
|
||||
MISTRAL_API_KEY: ""
|
||||
MISTRAL_MODEL: "mistral-small-latest"
|
||||
GROQ_API_KEY: ""
|
||||
@ -24,8 +22,6 @@ stringData:
|
||||
TELEGRAM_BOT_TOKEN: ""
|
||||
TELEGRAM_WEBHOOK_SECRET: ""
|
||||
---
|
||||
# Credentials consommés par le StatefulSet postgres.
|
||||
# Les mêmes valeurs doivent composer DATABASE_URL ci-dessus.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
@ -36,18 +32,3 @@ stringData:
|
||||
POSTGRES_USER: "ordinarthur"
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: "ordinarthur_os"
|
||||
---
|
||||
# Credentials du CronJob de backup (bucket S3-compatible à choisir avec Arthur).
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ordinarthur-os-backup-secrets
|
||||
namespace: ordinarthur-os
|
||||
type: Opaque
|
||||
stringData:
|
||||
# Même valeur que DATABASE_URL (utilisable par pg_dump).
|
||||
PGURL: ""
|
||||
# rclone remote name + bucket, ex. "b2:ordinarthur-os-backups"
|
||||
RCLONE_REMOTE: ""
|
||||
# Contenu d'un rclone.conf — monté ensuite côté cronjob si besoin.
|
||||
RCLONE_CONFIG: ""
|
||||
|
||||
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;
|
||||
@ -43,6 +43,20 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ export * from "./todos";
|
||||
export * from "./ai_actions";
|
||||
export * from "./daily_checkins";
|
||||
export * from "./calendar_events";
|
||||
export * from "./projects";
|
||||
|
||||
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;
|
||||
@ -2,13 +2,16 @@
|
||||
"name": "@ordinarthur-os/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -210,6 +210,80 @@ export const TodoAiEnrichApplyRequest = z.object({
|
||||
});
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -257,6 +331,14 @@ export const ProposedAction = z.discriminatedUnion("fn", [
|
||||
note: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
fn: z.literal("capture_idea"),
|
||||
args: z.object({
|
||||
content: z.string(),
|
||||
priority: z.number().int().min(0).max(3).optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
export type ProposedAction = z.infer<typeof ProposedAction>;
|
||||
|
||||
|
||||
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"]
|
||||
}
|
||||
56
pnpm-lock.yaml
generated
56
pnpm-lock.yaml
generated
@ -72,6 +72,15 @@ importers:
|
||||
|
||||
apps/pwa:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@18.3.1)
|
||||
'@ordinarthur-os/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
@ -717,6 +726,28 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
@ -4804,6 +4835,31 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user