add project

This commit is contained in:
ordinarthur 2026-04-16 16:23:01 +02:00
parent 7de7ef16b9
commit 6fdfbab996
36 changed files with 1923 additions and 127 deletions

View File

@ -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}')"
]
}
}

View File

@ -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

View File

@ -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"]

View File

@ -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 {

View File

@ -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 {}

View File

@ -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

View File

@ -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 {

View 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);
}
}

View 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 {}

View 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(),
};
}

View File

@ -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

View File

@ -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",

View 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;
}

View File

@ -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;
}
}

View 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>
);
}

View 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>
);
}

View File

@ -1,10 +1,11 @@
import { Link } from "@tanstack/react-router";
const NAV_ITEMS = [
{ to: "/", label: "Home", symbol: "◎" },
{ to: "/agenda", label: "Agenda", symbol: "◫" },
{ to: "/todos", label: "Todos", symbol: "□" },
{ to: "/jobs", label: "Jobs", symbol: "⚡" },
{ to: "/", label: "Home", symbol: "◎" },
{ to: "/agenda", label: "Agenda", symbol: "◫" },
{ to: "/todos", label: "Todos", symbol: "□" },
{ to: "/projects", label: "Projets", symbol: "◈" },
{ to: "/jobs", label: "Jobs", symbol: "⚡" },
] as const;
export function BottomNav() {

View File

@ -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,
}

View File

@ -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>

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/projects")({
component: () => <Outlet />,
});

View File

@ -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

View File

@ -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 } } }

View File

@ -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 }

View File

@ -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

View File

@ -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: ""

View 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);

View 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;

View File

@ -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
}
]
}

View File

@ -4,3 +4,4 @@ export * from "./todos";
export * from "./ai_actions";
export * from "./daily_checkins";
export * from "./calendar_events";
export * from "./projects";

View 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;

View File

@ -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": {

View File

@ -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>;

View 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
View File

@ -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':