diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5999219..181d3d4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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}')" ] } } diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e04606d..2d9eb19 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 979ac6e..9b84d47 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,24 +1,42 @@ -# Multi-stage build : build avec pnpm puis runtime minimal -FROM node:20-alpine AS deps -RUN corepack enable -WORKDIR /repo -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ -COPY apps/api/package.json apps/api/ -COPY packages/shared/package.json packages/shared/ -COPY packages/db/package.json packages/db/ -RUN pnpm install --frozen-lockfile || pnpm install +# ── Stage 1 : build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /workspace -FROM deps AS build +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +# package.json seuls d'abord (layer cache pnpm install) +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/api/package.json apps/api/ +COPY packages/shared/package.json packages/shared/ +COPY packages/db/package.json packages/db/ + +RUN pnpm install --frozen-lockfile + +# Sources complètes COPY . . + +# Build dans l'ordre des dépendances +RUN pnpm --filter @ordinarthur-os/shared build +RUN pnpm --filter @ordinarthur-os/db build RUN pnpm --filter @ordinarthur-os/api build +# Deploy propre : pnpm résout les workspace packages compilés vers /deploy/api +RUN pnpm --filter @ordinarthur-os/api deploy --prod /deploy/api + +# ── Stage 2 : runtime ──────────────────────────────────────────────────────── FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production -RUN corepack enable -COPY --from=build /repo/apps/api/dist ./dist -COPY --from=build /repo/apps/api/package.json ./package.json -COPY --from=build /repo/node_modules ./node_modules -COPY --from=build /repo/packages ./packages + +# Deps de prod isolées (inclut @ordinarthur-os/shared+db compilés) +COPY --from=builder /deploy/api/node_modules ./node_modules +COPY --from=builder /deploy/api/package.json ./ + +# Code compilé de l'API +COPY --from=builder /workspace/apps/api/dist ./dist + +# Migrations SQL (pour le job K3s migrate) +COPY --from=builder /workspace/packages/db/migrations ./packages/db/migrations + EXPOSE 3000 CMD ["node", "dist/main.js"] diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3c98363..700f29e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 { diff --git a/apps/api/src/modules/ai/ai.module.ts b/apps/api/src/modules/ai/ai.module.ts index 44e8a3b..cab1d8d 100644 --- a/apps/api/src/modules/ai/ai.module.ts +++ b/apps/api/src/modules/ai/ai.module.ts @@ -3,9 +3,11 @@ 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], + providers: [AiService, MistralClient, GroqClient], }) export class AiModule {} diff --git a/apps/api/src/modules/ai/ai.service.ts b/apps/api/src/modules/ai/ai.service.ts index 6824577..a0d38e7 100644 --- a/apps/api/src/modules/ai/ai.service.ts +++ b/apps/api/src/modules/ai/ai.service.ts @@ -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 diff --git a/apps/api/src/modules/ai/mistral.client.ts b/apps/api/src/modules/ai/mistral.client.ts index 56af1df..c9ce5c8 100644 --- a/apps/api/src/modules/ai/mistral.client.ts +++ b/apps/api/src/modules/ai/mistral.client.ts @@ -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 { diff --git a/apps/api/src/modules/projects/projects.controller.ts b/apps/api/src/modules/projects/projects.controller.ts new file mode 100644 index 0000000..252cd9d --- /dev/null +++ b/apps/api/src/modules/projects/projects.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/projects/projects.module.ts b/apps/api/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..d0c1ac3 --- /dev/null +++ b/apps/api/src/modules/projects/projects.module.ts @@ -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 {} diff --git a/apps/api/src/modules/projects/projects.service.ts b/apps/api/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..e1577ca --- /dev/null +++ b/apps/api/src/modules/projects/projects.service.ts @@ -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 { + const rows = await this.db + .select() + .from(projects) + .orderBy(asc(projects.createdAt)); + return rows.map(projectToDto); + } + + async getProject(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + await this.db.delete(projectSteps).where(eq(projectSteps.id, stepId)); + } + + // --------------------------------------------------------------------------- + // Ideas + // --------------------------------------------------------------------------- + + async listFreeIdeas(): Promise { + 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 { + 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 { + 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 { + 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 { + 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(), + }; +} diff --git a/apps/pwa/Dockerfile b/apps/pwa/Dockerfile index b552065..c863707 100644 --- a/apps/pwa/Dockerfile +++ b/apps/pwa/Dockerfile @@ -1,15 +1,28 @@ -FROM node:20-alpine AS build -RUN corepack enable -WORKDIR /repo -COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* ./ -COPY apps/pwa/package.json apps/pwa/ +# ── Stage 1 : build ────────────────────────────────────────────────────────── +FROM node:20-alpine AS builder +WORKDIR /workspace + +RUN corepack enable && corepack prepare pnpm@9.12.0 --activate + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ +COPY apps/pwa/package.json apps/pwa/ COPY packages/shared/package.json packages/shared/ -COPY packages/db/package.json packages/db/ -RUN pnpm install --frozen-lockfile || pnpm install +COPY packages/db/package.json packages/db/ + +RUN pnpm install --frozen-lockfile + COPY . . + +# L'URL de l'API est baked-in au build (Vite env var) +ARG VITE_API_BASE_URL=https://api.os.arthurbarre.fr +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + RUN pnpm --filter @ordinarthur-os/pwa build +# ── Stage 2 : runtime ──────────────────────────────────────────────────────── FROM nginx:1.27-alpine AS runtime -COPY --from=build /repo/apps/pwa/dist /usr/share/nginx/html + +COPY --from=builder /workspace/apps/pwa/dist /usr/share/nginx/html COPY apps/pwa/nginx.conf /etc/nginx/conf.d/default.conf + EXPOSE 80 diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 33335b8..7f8433c 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -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", diff --git a/apps/pwa/src/components/ai/VoiceButton.tsx b/apps/pwa/src/components/ai/VoiceButton.tsx new file mode 100644 index 0000000..151251e --- /dev/null +++ b/apps/pwa/src/components/ai/VoiceButton.tsx @@ -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("idle"); + const [result, setResult] = useState(null); + + const recorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(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("/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 ( + <> + + {phase === "reviewing" && result && ( + { setResult(null); setPhase("idle"); }} /> + )} + + ); + } + + // Default (same as MagicButton but simplified) + return ( + <> + + {phase === "reviewing" && result && ( + { 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; +} diff --git a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx index 4d90df3..fe4f8df 100644 --- a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx +++ b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx @@ -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; } } diff --git a/apps/pwa/src/components/projects/IdeaList.tsx b/apps/pwa/src/components/projects/IdeaList.tsx new file mode 100644 index 0000000..8e2484b --- /dev/null +++ b/apps/pwa/src/components/projects/IdeaList.tsx @@ -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(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 ( +
+ {/* Form */} +
+ Nouvelle idée +