From 32f3105bef20a11c36177ec0d3df5bcfee45aa1e Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 16 Apr 2026 10:46:57 +0200 Subject: [PATCH] pg todo 2 --- .claude/settings.local.json | 12 + apps/api/package.json | 1 + apps/api/src/config/env.ts | 11 +- apps/api/src/main.ts | 1 + .../api/src/modules/auth/bearer.middleware.ts | 4 +- apps/api/src/modules/jobs/jobs.controller.ts | 2 +- apps/api/src/modules/jobs/jobs.service.ts | 2 +- .../api/src/modules/todos/todos.controller.ts | 60 +++++ apps/api/src/modules/todos/todos.module.ts | 9 + apps/api/src/modules/todos/todos.service.ts | 209 +++++++++++++++++ apps/api/tsconfig.json | 4 + apps/pwa/src/routes/todos.tsx | 221 ++++++++++++++++++ packages/db/migrations/0002_todos.sql | 27 +++ packages/db/migrations/meta/_journal.json | 7 + packages/db/package.json | 15 +- packages/db/src/index.ts | 4 +- packages/db/src/migrate.ts | 3 +- packages/db/src/schema/index.ts | 1 + packages/db/src/schema/todos.ts | 34 +++ packages/db/tsconfig.json | 11 +- pnpm-lock.yaml | 3 + turbo.json | 3 +- 22 files changed, 623 insertions(+), 21 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 apps/api/src/modules/todos/todos.controller.ts create mode 100644 apps/api/src/modules/todos/todos.module.ts create mode 100644 apps/api/src/modules/todos/todos.service.ts create mode 100644 apps/pwa/src/routes/todos.tsx create mode 100644 packages/db/migrations/0002_todos.sql create mode 100644 packages/db/src/schema/todos.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6bc3dd7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm --filter @ordinarthur-os/db build)", + "Bash(pnpm --filter @ordinarthur-os/db tsc --noEmit)", + "Bash(pnpm --filter @ordinarthur-os/api add tsconfig-paths)", + "Read(//private/tmp/test_ts/pkg/**)", + "Bash(node -e \"const m = require\\('./a.ts'\\); console.log\\('CJS require result:', m\\);\")", + "Bash(pnpm --filter @ordinarthur-os/db migrate)" + ] + } +} diff --git a/apps/api/package.json b/apps/api/package.json index 53274a5..0eba93a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "postgres": "^3.4.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "tsconfig-paths": "^4.2.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 7b52a83..fdd35bb 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,9 +1,14 @@ import { z } from "zod"; - - -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; +// Charge apps/api/.env (si présent) avant de valider process.env. +// Utilise l'API native Node 20.12+ — pas de dépendance `dotenv`. +const envPath = path.resolve(__dirname, "..", "..", ".env"); +if (existsSync(envPath)) { + process.loadEnvFile(envPath); +} + const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "production", "test"]).default("development"), PORT: z.coerce.number().int().positive().default(3000), diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index fff480c..d1082f8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,4 +1,5 @@ import "reflect-metadata"; +import "tsconfig-paths/register"; import { NestFactory } from "@nestjs/core"; import { Logger } from "@nestjs/common"; import { AppModule } from "./app.module"; diff --git a/apps/api/src/modules/auth/bearer.middleware.ts b/apps/api/src/modules/auth/bearer.middleware.ts index c332093..a68999e 100644 --- a/apps/api/src/modules/auth/bearer.middleware.ts +++ b/apps/api/src/modules/auth/bearer.middleware.ts @@ -1,7 +1,7 @@ import { Injectable, NestMiddleware, UnauthorizedException, Inject } from "@nestjs/common"; import type { Request, Response, NextFunction } from "express"; -import { APP_CONFIG } from "../../config/config.module"; -import type { AppConfig } from "../../config/env"; +import { APP_CONFIG } from "@/config/config.module"; +import type { AppConfig } from "@/config/env"; @Injectable() export class BearerMiddleware implements NestMiddleware { diff --git a/apps/api/src/modules/jobs/jobs.controller.ts b/apps/api/src/modules/jobs/jobs.controller.ts index c221edb..9926235 100644 --- a/apps/api/src/modules/jobs/jobs.controller.ts +++ b/apps/api/src/modules/jobs/jobs.controller.ts @@ -16,7 +16,7 @@ import { JobPatchDto, JobSearchCriteriaUpsert, } from "@ordinarthur-os/shared"; -import { ZodPipe } from "../../lib/zod-pipe"; +import { ZodPipe } from "@/lib/zod-pipe"; import { JobsService } from "./jobs.service"; @Controller("jobs") diff --git a/apps/api/src/modules/jobs/jobs.service.ts b/apps/api/src/modules/jobs/jobs.service.ts index e96663a..9efd76d 100644 --- a/apps/api/src/modules/jobs/jobs.service.ts +++ b/apps/api/src/modules/jobs/jobs.service.ts @@ -10,7 +10,7 @@ import { JobSearchCriteriaUpsert, } from "@ordinarthur-os/shared"; import { and, arrayOverlaps, asc, desc, eq, gte, lt, sql } from "drizzle-orm"; -import { InjectDb, type Db } from "../../db/db.module"; +import { InjectDb, type Db } from "@/db/db.module"; const { jobs, jobSearchCriteria } = schema; const RETENTION_DAYS = 30; diff --git a/apps/api/src/modules/todos/todos.controller.ts b/apps/api/src/modules/todos/todos.controller.ts new file mode 100644 index 0000000..ff0fa7e --- /dev/null +++ b/apps/api/src/modules/todos/todos.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { + TodoAiEnrichApplyRequest, + TodoCreateDto, + TodoListQuery, + TodoPatchDto, +} from "@ordinarthur-os/shared"; +import { ZodPipe } from "@/lib/zod-pipe"; +import { TodosService } from "./todos.service"; + +@Controller("todos") +export class TodosController { + constructor(private readonly todos: TodosService) {} + + @Get() + list(@Query(new ZodPipe(TodoListQuery)) query: TodoListQuery) { + return this.todos.list(query); + } + + @Post() + create(@Body(new ZodPipe(TodoCreateDto)) input: TodoCreateDto) { + return this.todos.create(input); + } + + @Patch(":id") + patch( + @Param("id", new ParseUUIDPipe()) id: string, + @Body(new ZodPipe(TodoPatchDto)) patch: TodoPatchDto, + ) { + return this.todos.patch(id, patch); + } + + @Delete(":id") + remove(@Param("id", new ParseUUIDPipe()) id: string) { + return this.todos.remove(id); + } + + @Post(":id/ai-enrich") + aiEnrich(@Param("id", new ParseUUIDPipe()) id: string) { + return this.todos.aiEnrich(id); + } + + @Post(":id/ai-enrich/apply") + applyAiEnrich( + @Param("id", new ParseUUIDPipe()) id: string, + @Body(new ZodPipe(TodoAiEnrichApplyRequest)) body: TodoAiEnrichApplyRequest, + ) { + return this.todos.applyAiEnrich(id, body.draft); + } +} diff --git a/apps/api/src/modules/todos/todos.module.ts b/apps/api/src/modules/todos/todos.module.ts new file mode 100644 index 0000000..8f70f0e --- /dev/null +++ b/apps/api/src/modules/todos/todos.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { TodosController } from "./todos.controller"; +import { TodosService } from "./todos.service"; + +@Module({ + controllers: [TodosController], + providers: [TodosService], +}) +export class TodosModule {} diff --git a/apps/api/src/modules/todos/todos.service.ts b/apps/api/src/modules/todos/todos.service.ts new file mode 100644 index 0000000..f21b550 --- /dev/null +++ b/apps/api/src/modules/todos/todos.service.ts @@ -0,0 +1,209 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { schema } from "@ordinarthur-os/db"; +import { + Todo, + TodoAiEnrichDraft, + TodoAiEnrichResponse, + TodoCreateDto, + TodoListQuery, + TodoPatchDto, + TodoPriority, +} from "@ordinarthur-os/shared"; +import { and, arrayOverlaps, desc, eq, ilike, ne, sql } from "drizzle-orm"; +import { InjectDb, type Db } from "@/db/db.module"; + +const { todos } = schema; + +@Injectable() +export class TodosService { + constructor(@InjectDb() private readonly db: Db) {} + + async list(query: TodoListQuery): Promise { + const conditions = []; + + if (query.archived === true) { + conditions.push(eq(todos.status, "archived")); + } else if (!query.status) { + conditions.push(ne(todos.status, "archived")); + } + + if (query.status) conditions.push(eq(todos.status, query.status)); + if (typeof query.priority === "number") + conditions.push(eq(todos.priority, query.priority)); + if (query.context) conditions.push(ilike(todos.context, `%${query.context}%`)); + if (query.project_id) conditions.push(eq(todos.projectId, query.project_id)); + if (query.tags?.length) conditions.push(arrayOverlaps(todos.tags, query.tags)); + + const rows = await this.db + .select() + .from(todos) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(todos.createdAt)) + .limit(500); + + return rows.map(rowToTodo); + } + + async create(input: TodoCreateDto): Promise { + const rows = await this.db + .insert(todos) + .values(dtoToInsert(input)) + .returning(); + const row = rows[0]; + if (!row) throw new Error("Insert returned no row"); + return rowToTodo(row); + } + + async patch(id: string, patch: TodoPatchDto): Promise { + const existing = await this.getById(id); + const values = dtoToInsert({ ...existing, ...patch } as TodoCreateDto); + + // completed_at : si le patch le précise explicitement, on l'honore ; + // sinon on le dérive du status. + let completedAt: Date | null | undefined = undefined; + if ("completed_at" in patch && patch.completed_at !== undefined) { + completedAt = patch.completed_at ? new Date(patch.completed_at) : null; + } else if (patch.status === "done") { + completedAt = new Date(); + } else if (patch.status) { + completedAt = null; + } + + const [row] = await this.db + .update(todos) + .set({ + ...values, + ...(completedAt !== undefined ? { completedAt } : {}), + ...(patch.ai_enriched !== undefined ? { aiEnriched: patch.ai_enriched } : {}), + }) + .where(eq(todos.id, id)) + .returning(); + + if (!row) throw new NotFoundException(`Todo ${id} not found`); + return rowToTodo(row); + } + + async remove(id: string): Promise { + await this.db.delete(todos).where(eq(todos.id, id)); + } + + async aiEnrich(id: string): Promise { + const todo = await this.getById(id); + const draft = buildEnrichmentDraft(todo); + return { todo_id: todo.id, draft }; + } + + async applyAiEnrich(id: string, draft: TodoAiEnrichDraft): Promise { + const todo = await this.getById(id); + return this.patch(id, { + description: draft.description ?? todo.description, + priority: draft.priority ?? todo.priority, + tags: draft.tags ?? todo.tags, + context: draft.context ?? todo.context, + energy: draft.energy ?? todo.energy, + verification_steps: draft.verification_steps ?? todo.verification_steps, + ai_enriched: true, + }); + } + + private async getById(id: string): Promise { + const [row] = await this.db.select().from(todos).where(eq(todos.id, id)); + if (!row) throw new NotFoundException(`Todo ${id} not found`); + return rowToTodo(row); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function rowToTodo(row: typeof todos.$inferSelect): Todo { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + status: row.status as Todo["status"], + priority: row.priority as TodoPriority | null, + due_at: row.dueAt?.toISOString() ?? null, + tags: row.tags ?? [], + project_id: row.projectId ?? null, + checklist: (row.checklist as Todo["checklist"]) ?? [], + energy: row.energy as Todo["energy"] ?? null, + context: row.context ?? null, + recurrence: row.recurrence ?? null, + ticket_url: row.ticketUrl ?? null, + verification_steps: row.verificationSteps ?? [], + ai_enriched: row.aiEnriched, + created_at: row.createdAt.toISOString(), + completed_at: row.completedAt?.toISOString() ?? null, + }; +} + +function dtoToInsert(input: TodoCreateDto) { + return { + title: input.title, + description: input.description ?? null, + status: input.status ?? "inbox", + priority: input.priority ?? null, + dueAt: input.due_at ? new Date(input.due_at) : null, + tags: input.tags ?? [], + projectId: input.project_id ?? null, + checklist: input.checklist ?? [], + energy: input.energy ?? null, + context: input.context ?? null, + recurrence: input.recurrence ?? null, + ticketUrl: input.ticket_url ?? null, + verificationSteps: input.verification_steps ?? [], + }; +} + +// --------------------------------------------------------------------------- +// Enrichissement IA heuristique (remplacé par Mistral en Phase 5) +// --------------------------------------------------------------------------- + +function buildEnrichmentDraft(todo: Todo): TodoAiEnrichDraft { + const title = todo.title.toLowerCase(); + const tags = inferTags(todo.title, todo.tags); + + if (title.includes("appeler") || title.includes("call")) { + return { + description: todo.description ?? "Préparer les points, lancer l'appel, noter le prochain pas.", + priority: todo.priority ?? 2, + tags, + context: todo.context ?? "@phone", + energy: todo.energy ?? "med", + }; + } + + if (title.includes("mail") || title.includes("email") || title.includes("répondre")) { + return { + description: todo.description ?? "Rédiger, vérifier les PJ et envoyer.", + priority: todo.priority ?? 1, + tags, + context: todo.context ?? "@laptop", + energy: todo.energy ?? "low", + }; + } + + return { + description: todo.description ?? "Découper en une première action concrète et avancer.", + priority: todo.priority ?? 1, + tags, + context: todo.context ?? "@focus", + energy: todo.energy ?? "med", + }; +} + +function inferTags(title: string, currentTags: string[]) { + const base = new Set(currentTags.map((t) => t.trim()).filter(Boolean)); + const tokens = title + .toLowerCase() + .split(/[^a-z0-9àâçéèêëîïôûùüÿñæœ]+/i) + .filter((t) => t.length >= 4) + .filter((t) => !STOPWORDS.has(t)) + .slice(0, 4); + for (const token of tokens) base.add(token); + return Array.from(base); +} + +const STOPWORDS = new Set(["avec", "dans", "pour", "faire", "plus", "arthur", "todo", "that", "this", "from"]); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 43e2575..0c9e110 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,6 +8,10 @@ "emitDecoratorMetadata": true, "outDir": "dist", "rootDir": "src", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, "verbatimModuleSyntax": false, "isolatedModules": false }, diff --git a/apps/pwa/src/routes/todos.tsx b/apps/pwa/src/routes/todos.tsx new file mode 100644 index 0000000..3c216b1 --- /dev/null +++ b/apps/pwa/src/routes/todos.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Todo, TodoCreateDto, TodoPatchDto, TodoStatus } from "@ordinarthur-os/shared"; +import { api } from "@/api/client"; +import { BigHeading, Label, SectionHeader } from "@/design"; + +export const Route = createFileRoute("/todos")({ component: TodosPage }); + +function TodosPage() { + const queryClient = useQueryClient(); + const [input, setInput] = useState(""); + const [showDone, setShowDone] = useState(false); + + const todos = useQuery({ + queryKey: ["todos", showDone], + queryFn: () => + api(showDone ? "/todos?status=done" : "/todos"), + }); + + const createTodo = useMutation({ + mutationFn: (payload: TodoCreateDto) => + api("/todos", { method: "POST", body: JSON.stringify(payload) }), + onSuccess: () => { + setInput(""); + void queryClient.invalidateQueries({ queryKey: ["todos"] }); + }, + }); + + const patchTodo = useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: TodoPatchDto }) => + api(`/todos/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }), + }); + + const deleteTodo = useMutation({ + mutationFn: (id: string) => api(`/todos/${id}`, { method: "DELETE" }), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["todos"] }), + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const title = input.trim(); + if (!title) return; + createTodo.mutate({ title, status: "inbox", tags: [], checklist: [], verification_steps: [] }); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e as unknown as React.FormEvent); + } + } + + const data = todos.data ?? []; + const active = data.filter((t) => t.status !== "done" && t.status !== "archived"); + const done = data.filter((t) => t.status === "done"); + + return ( +
+
+ + + Capture rapide, traitement via le magic button. + +

+ Écris une tâche, appuie sur Entrée. Le reste (priorité, contexte, tags) sera rempli par + le bouton vocal en Phase 5. +

+
+ + + +
+