add pg + todo ok
This commit is contained in:
parent
9c93e74318
commit
f851da4677
@ -123,6 +123,7 @@ theme: {
|
|||||||
|
|
||||||
### Règles visuelles
|
### Règles visuelles
|
||||||
|
|
||||||
|
- Le produit reste un outil personnel avant tout : clarté d'usage et vitesse priment sur la démonstration visuelle
|
||||||
- Bordures partout : sections, grid cells, nav
|
- Bordures partout : sections, grid cells, nav
|
||||||
- Pas d'ombre portée (shadow-none)
|
- Pas d'ombre portée (shadow-none)
|
||||||
- Imagery grayscale par défaut
|
- Imagery grayscale par défaut
|
||||||
|
|||||||
12
PLAN.md
12
PLAN.md
@ -9,7 +9,7 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
|
|||||||
- Dashboard clair de ce qu'il fait / veut faire
|
- Dashboard clair de ce qu'il fait / veut faire
|
||||||
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
||||||
- Pas de "weekly review" automatique, pas de nudges
|
- Pas de "weekly review" automatique, pas de nudges
|
||||||
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → transcription → création d'une todo / idée / étape projet / événement agenda, avec validation explicite avant écriture en base
|
- Une fonctionnalité signature : le **bouton "Parler"** — enregistrement vocal → Groq Whisper → Mistral function calling → création/enrichissement de todos, idées ou étapes projet, avec validation explicite avant écriture en base. **C'est le cœur du produit.** Les formulaires manuels sont intentionnellement minimalistes.
|
||||||
|
|
||||||
## Principes directeurs
|
## Principes directeurs
|
||||||
|
|
||||||
@ -57,11 +57,11 @@ Un assistant personnel qui aide Arthur à s'organiser **sans le déresponsabilis
|
|||||||
- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`)
|
- Rétention : jobs >30j auto-archivés (soft delete via `archived=true`)
|
||||||
- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h
|
- Le scheduled task Claude Code (hors repo) lit `/jobs/criteria?active=true` et push les résultats via `/jobs/ingest` quotidiennement à 7h
|
||||||
|
|
||||||
### Phase 2 — Todos riches
|
### Phase 2 — Todos (capture rapide)
|
||||||
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet)
|
- Migration : table `todos`
|
||||||
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation)
|
- API : CRUD complet (schéma riche pour que la Phase 5 puisse tout remplir via function calling)
|
||||||
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline
|
- PWA : **UI volontairement minimale** — textarea de capture + liste plate (checkbox done / delete). Pas de filtres, pas de formulaire de métadonnées. La priorité, le contexte, les tags seront remplis par le **magic button vocal (Phase 5)**.
|
||||||
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations`
|
- Les champs riches (priority, context, tags, due_at, checklist…) restent dans le schéma DB et l'API pour la Phase 5.
|
||||||
|
|
||||||
### Phase 3 — Projets + Kanban
|
### Phase 3 — Projets + Kanban
|
||||||
- Migrations : `projects`, `project_steps`, `project_ideas`
|
- Migrations : `projects`, `project_steps`, `project_ideas`
|
||||||
|
|||||||
@ -5,9 +5,10 @@ import { HealthModule } from "./modules/health/health.module";
|
|||||||
import { AuthModule } from "./modules/auth/auth.module";
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
import { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||||
import { JobsModule } from "./modules/jobs/jobs.module";
|
import { JobsModule } from "./modules/jobs/jobs.module";
|
||||||
|
import { TodosModule } from "./modules/todos/todos.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
|
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
@ -14,6 +16,7 @@ const EnvSchema = z.object({
|
|||||||
// car Drizzle préfixe lui-même le schéma (`pgSchema`).
|
// car Drizzle préfixe lui-même le schéma (`pgSchema`).
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
|
|
||||||
|
// Phase 5+ — optionnels jusque-là
|
||||||
MISTRAL_API_KEY: z.string().optional(),
|
MISTRAL_API_KEY: z.string().optional(),
|
||||||
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
||||||
GROQ_API_KEY: z.string().optional(),
|
GROQ_API_KEY: z.string().optional(),
|
||||||
@ -34,53 +37,12 @@ let cached: AppConfig | null = null;
|
|||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
loadEnvFile();
|
|
||||||
|
|
||||||
const parsed = EnvSchema.safeParse(process.env);
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
||||||
throw new Error("Invalid environment configuration");
|
throw new Error("Invalid environment configuration");
|
||||||
}
|
}
|
||||||
|
|
||||||
cached = parsed.data;
|
cached = parsed.data;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadEnvFile() {
|
|
||||||
const candidates = [
|
|
||||||
path.resolve(process.cwd(), ".env"),
|
|
||||||
path.resolve(process.cwd(), "apps/api/.env"),
|
|
||||||
path.resolve(__dirname, "../../.env"),
|
|
||||||
];
|
|
||||||
|
|
||||||
const envPath = candidates.find((candidate) => existsSync(candidate));
|
|
||||||
if (!envPath) return;
|
|
||||||
|
|
||||||
const raw = readFileSync(envPath, "utf8");
|
|
||||||
for (const line of raw.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
|
|
||||||
const separatorIndex = trimmed.indexOf("=");
|
|
||||||
if (separatorIndex === -1) continue;
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, separatorIndex).trim();
|
|
||||||
const value = trimmed.slice(separatorIndex + 1).trim();
|
|
||||||
|
|
||||||
if (!key || process.env[key] !== undefined) continue;
|
|
||||||
process.env[key] = stripWrappingQuotes(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripWrappingQuotes(value: string) {
|
|
||||||
if (
|
|
||||||
(value.startsWith('"') && value.endsWith('"')) ||
|
|
||||||
(value.startsWith("'") && value.endsWith("'"))
|
|
||||||
) {
|
|
||||||
return value.slice(1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -9,10 +9,16 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as TodosRouteImport } from './routes/todos'
|
||||||
import { Route as JobsRouteImport } from './routes/jobs'
|
import { Route as JobsRouteImport } from './routes/jobs'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||||
|
|
||||||
|
const TodosRoute = TodosRouteImport.update({
|
||||||
|
id: '/todos',
|
||||||
|
path: '/todos',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const JobsRoute = JobsRouteImport.update({
|
const JobsRoute = JobsRouteImport.update({
|
||||||
id: '/jobs',
|
id: '/jobs',
|
||||||
path: '/jobs',
|
path: '/jobs',
|
||||||
@ -32,35 +38,46 @@ const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/jobs': typeof JobsRoute
|
'/jobs': typeof JobsRoute
|
||||||
|
'/todos': typeof TodosRoute
|
||||||
'/settings/jobs': typeof SettingsJobsRoute
|
'/settings/jobs': typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/jobs' | '/settings/jobs'
|
fullPaths: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/jobs' | '/settings/jobs'
|
to: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
id: '__root__' | '/' | '/jobs' | '/settings/jobs'
|
id: '__root__' | '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
JobsRoute: typeof JobsRoute
|
JobsRoute: typeof JobsRoute
|
||||||
|
TodosRoute: typeof TodosRoute
|
||||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/todos': {
|
||||||
|
id: '/todos'
|
||||||
|
path: '/todos'
|
||||||
|
fullPath: '/todos'
|
||||||
|
preLoaderRoute: typeof TodosRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/jobs': {
|
'/jobs': {
|
||||||
id: '/jobs'
|
id: '/jobs'
|
||||||
path: '/jobs'
|
path: '/jobs'
|
||||||
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
JobsRoute: JobsRoute,
|
JobsRoute: JobsRoute,
|
||||||
|
TodosRoute: TodosRoute,
|
||||||
SettingsJobsRoute: SettingsJobsRoute,
|
SettingsJobsRoute: SettingsJobsRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@ -17,6 +17,7 @@ function RootLayout() {
|
|||||||
<nav className="flex items-center gap-4">
|
<nav className="flex items-center gap-4">
|
||||||
<NavLink to="/">Dashboard</NavLink>
|
<NavLink to="/">Dashboard</NavLink>
|
||||||
<NavLink to="/jobs">Jobs</NavLink>
|
<NavLink to="/jobs">Jobs</NavLink>
|
||||||
|
<NavLink to="/todos">Todos</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -72,8 +72,8 @@ function Dashboard() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Label prefix="[ 03 ]">PRIORITÉ</Label>
|
<Label prefix="[ 03 ]">PRIORITÉ</Label>
|
||||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
||||||
La Phase 1 est la première verticale métier: ingestion backend, listing éditorial,
|
Les verticales métier arrivent les unes après les autres: jobs pour la veille,
|
||||||
filtres de recherche et critères intégrés directement dans la vue jobs.
|
puis todos pour piloter l’exécution au quotidien.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/jobs"
|
href="/jobs"
|
||||||
@ -85,9 +85,15 @@ function Dashboard() {
|
|||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<Label prefix="[ 04 ]">MODE</Label>
|
<Label prefix="[ 04 ]">MODE</Label>
|
||||||
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
<p className="max-w-xl font-sans text-sm leading-6 text-ink">
|
||||||
Les presets de critères vivent maintenant dans le header de la page jobs pour rester
|
L’outil reste mono-utilisateur, simple, et chaque action IA continue de passer par une
|
||||||
au plus près du tri quotidien.
|
confirmation explicite avant écriture.
|
||||||
</p>
|
</p>
|
||||||
|
<a
|
||||||
|
href="/todos"
|
||||||
|
className="inline-flex items-center border border-ink px-3 py-2 font-mono text-[11px] uppercase tracking-label text-ink transition-colors hover:bg-ink hover:text-bg"
|
||||||
|
>
|
||||||
|
Ouvrir les todos
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</GridFrame>
|
</GridFrame>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -113,6 +113,103 @@ export const JobSearchCriteriaUpsert = z.object({
|
|||||||
});
|
});
|
||||||
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
|
export type JobSearchCriteriaUpsert = z.infer<typeof JobSearchCriteriaUpsert>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 2 — Todos
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const TodoStatus = z.enum(["inbox", "todo", "doing", "done", "archived"]);
|
||||||
|
export type TodoStatus = z.infer<typeof TodoStatus>;
|
||||||
|
|
||||||
|
export const TodoEnergy = z.enum(["low", "med", "high"]);
|
||||||
|
export type TodoEnergy = z.infer<typeof TodoEnergy>;
|
||||||
|
|
||||||
|
export const TodoChecklistItem = z.object({
|
||||||
|
text: z.string().min(1),
|
||||||
|
done: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export type TodoChecklistItem = z.infer<typeof TodoChecklistItem>;
|
||||||
|
|
||||||
|
export const TodoPriority = z.number().int().min(0).max(3);
|
||||||
|
export type TodoPriority = z.infer<typeof TodoPriority>;
|
||||||
|
|
||||||
|
export const Todo = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
status: TodoStatus,
|
||||||
|
priority: TodoPriority.nullable(),
|
||||||
|
due_at: z.string().nullable(),
|
||||||
|
tags: z.array(z.string()),
|
||||||
|
project_id: z.string().uuid().nullable(),
|
||||||
|
checklist: z.array(TodoChecklistItem),
|
||||||
|
energy: TodoEnergy.nullable(),
|
||||||
|
context: z.string().nullable(),
|
||||||
|
recurrence: z.string().nullable(),
|
||||||
|
ticket_url: z.string().nullable(),
|
||||||
|
verification_steps: z.array(z.string()),
|
||||||
|
ai_enriched: z.boolean(),
|
||||||
|
created_at: z.string(),
|
||||||
|
completed_at: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type Todo = z.infer<typeof Todo>;
|
||||||
|
|
||||||
|
export const TodoCreateDto = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: TodoStatus.default("inbox"),
|
||||||
|
priority: TodoPriority.optional().nullable(),
|
||||||
|
due_at: z.string().datetime().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
project_id: z.string().uuid().optional().nullable(),
|
||||||
|
checklist: z.array(TodoChecklistItem).default([]),
|
||||||
|
energy: TodoEnergy.optional().nullable(),
|
||||||
|
context: z.string().optional().nullable(),
|
||||||
|
recurrence: z.string().optional().nullable(),
|
||||||
|
ticket_url: z.string().url().optional().nullable(),
|
||||||
|
verification_steps: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
export type TodoCreateDto = z.infer<typeof TodoCreateDto>;
|
||||||
|
|
||||||
|
export const TodoPatchDto = TodoCreateDto.partial().extend({
|
||||||
|
ai_enriched: z.boolean().optional(),
|
||||||
|
completed_at: z.string().datetime().optional().nullable(),
|
||||||
|
});
|
||||||
|
export type TodoPatchDto = z.infer<typeof TodoPatchDto>;
|
||||||
|
|
||||||
|
export const TodoListQuery = z.object({
|
||||||
|
status: TodoStatus.optional(),
|
||||||
|
priority: z.coerce.number().int().min(0).max(3).optional(),
|
||||||
|
context: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
project_id: z.string().uuid().optional(),
|
||||||
|
archived: z
|
||||||
|
.union([z.boolean(), z.enum(["true", "false"])])
|
||||||
|
.transform((v) => (typeof v === "boolean" ? v : v === "true"))
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
export type TodoListQuery = z.infer<typeof TodoListQuery>;
|
||||||
|
|
||||||
|
export const TodoAiEnrichDraft = z.object({
|
||||||
|
description: z.string().optional(),
|
||||||
|
priority: TodoPriority.optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
context: z.string().optional(),
|
||||||
|
energy: TodoEnergy.optional(),
|
||||||
|
verification_steps: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
export type TodoAiEnrichDraft = z.infer<typeof TodoAiEnrichDraft>;
|
||||||
|
|
||||||
|
export const TodoAiEnrichResponse = z.object({
|
||||||
|
todo_id: z.string().uuid(),
|
||||||
|
draft: TodoAiEnrichDraft,
|
||||||
|
});
|
||||||
|
export type TodoAiEnrichResponse = z.infer<typeof TodoAiEnrichResponse>;
|
||||||
|
|
||||||
|
export const TodoAiEnrichApplyRequest = z.object({
|
||||||
|
draft: TodoAiEnrichDraft,
|
||||||
|
});
|
||||||
|
export type TodoAiEnrichApplyRequest = z.infer<typeof TodoAiEnrichApplyRequest>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
// Phase 5 — AI proposed actions (kept here so PWA + API agree)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user