add pg + todo ok
This commit is contained in:
parent
9c93e74318
commit
f851da4677
@ -123,6 +123,7 @@ theme: {
|
||||
|
||||
### 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
|
||||
- Pas d'ombre portée (shadow-none)
|
||||
- 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
|
||||
- Aucune action automatique invasive : l'IA propose, Arthur confirme d'un clic
|
||||
- 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
|
||||
|
||||
@ -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`)
|
||||
- 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
|
||||
- Migration : table `todos` (voir ARCHITECTURE.md pour le schéma complet)
|
||||
- API : CRUD + endpoints `/todos/:id/ai-enrich` (renvoie draft, ne sauve pas) et `/ai-enrich/apply` (après confirmation)
|
||||
- PWA : route `/todos` avec inbox, filtres (status, priority, context, tags, project), édition inline
|
||||
- Offline : mutation queue via Dexie, replay à la reconnexion, déduplication côté API via table `client_mutations`
|
||||
### Phase 2 — Todos (capture rapide)
|
||||
- Migration : table `todos`
|
||||
- API : CRUD complet (schéma riche pour que la Phase 5 puisse tout remplir via function calling)
|
||||
- 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)**.
|
||||
- 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
|
||||
- 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 { BearerMiddleware } from "./modules/auth/bearer.middleware";
|
||||
import { JobsModule } from "./modules/jobs/jobs.module";
|
||||
import { TodosModule } from "./modules/todos/todos.module";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule],
|
||||
imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
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`).
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// Phase 5+ — optionnels jusque-là
|
||||
MISTRAL_API_KEY: z.string().optional(),
|
||||
MISTRAL_MODEL: z.string().default("mistral-small-latest"),
|
||||
GROQ_API_KEY: z.string().optional(),
|
||||
@ -34,53 +37,12 @@ let cached: AppConfig | null = null;
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
if (cached) return cached;
|
||||
|
||||
loadEnvFile();
|
||||
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[config] invalid env:", parsed.error.flatten().fieldErrors);
|
||||
throw new Error("Invalid environment configuration");
|
||||
}
|
||||
|
||||
cached = parsed.data;
|
||||
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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as TodosRouteImport } from './routes/todos'
|
||||
import { Route as JobsRouteImport } from './routes/jobs'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsJobsRouteImport } from './routes/settings.jobs'
|
||||
|
||||
const TodosRoute = TodosRouteImport.update({
|
||||
id: '/todos',
|
||||
path: '/todos',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const JobsRoute = JobsRouteImport.update({
|
||||
id: '/jobs',
|
||||
path: '/jobs',
|
||||
@ -32,35 +38,46 @@ const SettingsJobsRoute = SettingsJobsRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/jobs': typeof JobsRoute
|
||||
'/todos': typeof TodosRoute
|
||||
'/settings/jobs': typeof SettingsJobsRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/jobs' | '/settings/jobs'
|
||||
fullPaths: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/jobs' | '/settings/jobs'
|
||||
id: '__root__' | '/' | '/jobs' | '/settings/jobs'
|
||||
to: '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
id: '__root__' | '/' | '/jobs' | '/todos' | '/settings/jobs'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
JobsRoute: typeof JobsRoute
|
||||
TodosRoute: typeof TodosRoute
|
||||
SettingsJobsRoute: typeof SettingsJobsRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/todos': {
|
||||
id: '/todos'
|
||||
path: '/todos'
|
||||
fullPath: '/todos'
|
||||
preLoaderRoute: typeof TodosRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/jobs': {
|
||||
id: '/jobs'
|
||||
path: '/jobs'
|
||||
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
JobsRoute: JobsRoute,
|
||||
TodosRoute: TodosRoute,
|
||||
SettingsJobsRoute: SettingsJobsRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@ -17,6 +17,7 @@ function RootLayout() {
|
||||
<nav className="flex items-center gap-4">
|
||||
<NavLink to="/">Dashboard</NavLink>
|
||||
<NavLink to="/jobs">Jobs</NavLink>
|
||||
<NavLink to="/todos">Todos</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -72,8 +72,8 @@ function Dashboard() {
|
||||
<div className="p-6 space-y-4">
|
||||
<Label prefix="[ 03 ]">PRIORITÉ</Label>
|
||||
<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,
|
||||
filtres de recherche et critères intégrés directement dans la vue jobs.
|
||||
Les verticales métier arrivent les unes après les autres: jobs pour la veille,
|
||||
puis todos pour piloter l’exécution au quotidien.
|
||||
</p>
|
||||
<a
|
||||
href="/jobs"
|
||||
@ -85,9 +85,15 @@ function Dashboard() {
|
||||
<div className="p-6 space-y-4">
|
||||
<Label prefix="[ 04 ]">MODE</Label>
|
||||
<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
|
||||
au plus près du tri quotidien.
|
||||
L’outil reste mono-utilisateur, simple, et chaque action IA continue de passer par une
|
||||
confirmation explicite avant écriture.
|
||||
</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>
|
||||
</GridFrame>
|
||||
</div>
|
||||
|
||||
@ -113,6 +113,103 @@ export const JobSearchCriteriaUpsert = z.object({
|
||||
});
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user