Checkpoint: Migration complète vers authentification locale (JWT + bcrypt) : schéma DB (10 tables), routes tRPC (auth, users, etablissements, inventaire, capex, opex, parametres), page de login avec branding Itinova/Santinova, protection des routes, onglets Établissements et Utilisateurs branchés sur tRPC, import inventaire/établissements/utilisateurs via tRPC, 14 tests Vitest passants.

This commit is contained in:
Manus
2026-06-10 12:17:07 +00:00
parent fe14ad99e1
commit caf95da1c1
59 changed files with 9739 additions and 399 deletions

1
.gitignore vendored
View File

@@ -44,6 +44,7 @@ pids
*.pid
*.seed
*.pid.lock
*.bak
# Coverage directory used by tools like istanbul
coverage/

View File

@@ -1,5 +1,35 @@
dist
node_modules
.git
*.min.js
*.min.css
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Generated files
*.tsbuildinfo
coverage/
# Package files
package-lock.json
pnpm-lock.yaml
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# Environment files
.env*
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db

1090
README.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import { Route, Switch, useLocation } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import { ParametresProvider } from "./contexts/ParametresContext";
@@ -9,6 +9,7 @@ import { AnneeProvider } from "./contexts/AnneeContext";
import { ImportModalProvider, useImportModal } from "./contexts/ImportModalContext";
import { ImportModal } from "./components/ImportModal";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Parametres from "./pages/Parametres";
import Budget2027 from "./pages/Budget2027";
import DsiCapex from "./pages/DsiCapex";
@@ -16,19 +17,57 @@ import DsiOpex from "./pages/DsiOpex";
import Santinova from "./pages/Santinova";
import SoinsSante from "./pages/SoinsSante";
import StExupery from "./pages/StExupery";
import { useAuth } from "./_core/hooks/useAuth";
import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton";
/**
* Garde de route — redirige vers /login si non authentifié
*/
function ProtectedRoute({ component: Component }: { component: React.ComponentType }) {
const { user, loading } = useAuth();
const [, navigate] = useLocation();
if (loading) return <DashboardLayoutSkeleton />;
if (!user) {
navigate("/login");
return null;
}
return <Component />;
}
function Router() {
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path={"/budget2027"} component={Budget2027} />
<Route path={"/dsi-capex"} component={DsiCapex} />
<Route path={"/dsi-opex"} component={DsiOpex} />
<Route path={"/santinova"} component={Santinova} />
<Route path={"/soins-sante"} component={SoinsSante} />
<Route path={"/st-exupery"} component={StExupery} />
<Route path={"/parametres"} component={Parametres} />
<Route path={"/404"} component={NotFound} />
{/* Page de login — publique */}
<Route path="/login" component={Login} />
{/* Routes protégées */}
<Route path="/">
<ProtectedRoute component={Home} />
</Route>
<Route path="/budget2027">
<ProtectedRoute component={Budget2027} />
</Route>
<Route path="/dsi-capex">
<ProtectedRoute component={DsiCapex} />
</Route>
<Route path="/dsi-opex">
<ProtectedRoute component={DsiOpex} />
</Route>
<Route path="/santinova">
<ProtectedRoute component={Santinova} />
</Route>
<Route path="/soins-sante">
<ProtectedRoute component={SoinsSante} />
</Route>
<Route path="/st-exupery">
<ProtectedRoute component={StExupery} />
</Route>
<Route path="/parametres">
<ProtectedRoute component={Parametres} />
</Route>
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>
);

View File

@@ -0,0 +1,84 @@
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import { TRPCClientError } from "@trpc/client";
import { useCallback, useEffect, useMemo } from "react";
type UseAuthOptions = {
redirectOnUnauthenticated?: boolean;
redirectPath?: string;
};
export function useAuth(options?: UseAuthOptions) {
const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
options ?? {};
const utils = trpc.useUtils();
const meQuery = trpc.auth.me.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
});
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
},
});
const logout = useCallback(async () => {
try {
await logoutMutation.mutateAsync();
} catch (error: unknown) {
if (
error instanceof TRPCClientError &&
error.data?.code === "UNAUTHORIZED"
) {
return;
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
};
}, [
meQuery.data,
meQuery.error,
meQuery.isLoading,
logoutMutation.error,
logoutMutation.isPending,
]);
useEffect(() => {
if (!redirectOnUnauthenticated) return;
if (meQuery.isLoading || logoutMutation.isPending) return;
if (state.user) return;
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
}, [
redirectOnUnauthenticated,
redirectPath,
logoutMutation.isPending,
meQuery.isLoading,
state.user,
]);
return {
...state,
refresh: () => meQuery.refetch(),
logout,
};
}

View File

@@ -0,0 +1,335 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -13,6 +13,8 @@
import { useState } from 'react';
import { useImportModal } from '../contexts/ImportModalContext';
import { useLocation } from 'wouter';
import { useAuth } from '../_core/hooks/useAuth';
import { LogOut, User } from 'lucide-react';
import {
BarChart3,
Building2,
@@ -172,6 +174,7 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
const [location, navigate] = useLocation();
const [openMenus, setOpenMenus] = useState<Set<string>>(() => getInitialOpenSections(location));
const { openModal: openImportModal } = useImportModal();
const { user, logout } = useAuth();
// Mode accordéon : ferme les autres sections du même niveau lors de l'ouverture
const toggleSection = (id: string) => {
@@ -336,6 +339,32 @@ export function AppSidebar({ collapsed = false, onToggle }: AppSidebarProps) {
)}
</button>
{/* Utilisateur connecté + déconnexion */}
{user && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10">
<div className="w-7 h-7 rounded-full bg-blue-500/40 flex items-center justify-center flex-shrink-0">
<User className="w-3.5 h-3.5 text-blue-200" />
</div>
{!collapsed && (
<>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white/90 truncate">
{user.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user.login}
</p>
<p className="text-[10px] text-white/40 truncate capitalize">{user.role}</p>
</div>
<button
onClick={() => logout()}
title="Se déconnecter"
className="text-white/30 hover:text-white/80 transition-colors p-1 rounded"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
)}
<button
onClick={() => navigate(SETTINGS_ITEM.path)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all duration-150 text-left group ${

View File

@@ -0,0 +1,264 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
if (loading) {
return <DashboardLayoutSkeleton />
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-semibold tracking-tight text-center">
Sign in to continue
</h1>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Access to this dashboard requires authentication. Continue to launch the login flow.
</p>
</div>
<Button
onClick={() => {
window.location.href = getLoginUrl();
}}
size="lg"
className="w-full shadow-lg hover:shadow-xl transition-all"
>
Sign in
</Button>
</div>
</div>
);
}
return (
<SidebarProvider
style={
{
"--sidebar-width": `${sidebarWidth}px`,
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
{children}
</DashboardLayoutContent>
</SidebarProvider>
);
}
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
if (isCollapsed) {
setIsResizing(false);
}
}, [isCollapsed]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, setSidebarWidth]);
return (
<>
<div className="relative" ref={sidebarRef}>
<Sidebar
collapsible="icon"
className="border-r-0"
disableTransition={isResizing}
>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center gap-3 px-2 transition-all w-full">
<button
onClick={toggleSidebar}
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
aria-label="Toggle navigation"
>
<PanelLeft className="h-4 w-4 text-muted-foreground" />
</button>
{!isCollapsed ? (
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold tracking-tight truncate">
Navigation
</span>
</div>
) : null}
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton
isActive={isActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{(user?.firstName || user?.login)?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.firstName ? `${user.firstName} ${user.lastName || ''}`.trim() : user?.login || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
onMouseDown={() => {
if (isCollapsed) return;
setIsResizing(true);
}}
style={{ zIndex: 50 }}
/>
</div>
<SidebarInset>
{isMobile && (
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
<div className="flex items-center gap-2">
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1">
<span className="tracking-tight text-foreground">
{activeMenuItem?.label ?? "Menu"}
</span>
</div>
</div>
</div>
</div>
)}
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Skeleton } from './ui/skeleton';
export function DashboardLayoutSkeleton() {
return (
<div className="flex min-h-screen bg-background">
{/* Sidebar skeleton */}
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</div>
</div>
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
// ImportModal.tsx — Fenêtre d'import de données (Inventaire, Établissements, Utilisateurs)
// Design: Corporate Modernism — Itinova Budget SI
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { trpc } from '@/lib/trpc';
import { useAnnee, getInventaireStorageKey } from '../contexts/AnneeContext';
import * as XLSX from 'xlsx';
import {
@@ -253,6 +254,17 @@ interface ImportModalProps {
export function ImportModal({ open, onClose }: ImportModalProps) {
const { annee } = useAnnee();
const utils = trpc.useUtils();
const importInventaireMutation = trpc.inventaire.import.useMutation({
onSuccess: () => {
utils.inventaire.get.invalidate();
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
},
});
const upsertEtabMutation = trpc.etablissements.upsert.useMutation({
onSuccess: () => utils.etablissements.list.invalidate(),
});
const importUsersMutation = trpc.users.importBulk.useMutation();
const [tab, setTab] = useState<ImportTab>('inventaire');
const [inventaireStatus, setInventaireStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [inventaireMsg, setInventaireMsg] = useState('');
@@ -514,14 +526,24 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
return;
}
// ── Sauvegarder les données complètes de l'inventaire dans localStorage ──────
// Structure : { meta: {...}, etablissements: [ { code, nom, fixes[], portables[], stats } ] }
// ── Construire le payload pour tRPC ──────────────────────────────────────
const postesPayload: Array<{ etablissementCode: string; libelle?: string | null; typePoste: 'fixe' | 'portable'; dateRef?: string | null; ageAns?: string | null; modele?: string | null; fabricant?: string | null }> = [];
etabData.forEach((d, code) => {
d.fixes.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'fixe', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null }));
d.portables.forEach(p => postesPayload.push({ etablissementCode: code, libelle: p.libelle || null, typePoste: 'portable', dateRef: p.date_achat || null, ageAns: p.age_ans !== null ? String(p.age_ans) : null, modele: p.modele || null, fabricant: p.fabricant || null }));
});
// ── Envoyer à la base de données via tRPC ──────────────────────────────────
const result = await importInventaireMutation.mutateAsync({ annee, filename: file.name, postes: postesPayload });
// ── Aussi sauvegarder dans localStorage pour compatibilité avec les pages existantes ──
const etablissementsArray = Array.from(etabData.entries()).map(([code, d]) => ({
code,
nom: d.nom,
fixes: d.fixes,
portables: d.portables,
stats: { // stats provisoires, recalculées dynamiquement par useBudgetData
stats: {
nb_fixes_total: d.fixes.length,
nb_portables_total: d.portables.length,
nb_fixes_renouveler: 0,
@@ -533,63 +555,27 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
budget_total: 0,
},
}));
const inventaireData = {
meta: {
date_calcul: new Date().toISOString(),
date_reference: `${annee}-01-01`,
nb_etablissements: etabData.size,
total_fixes: nbFixes,
total_portables: nbPortables,
filename: file.name,
},
meta: { date_calcul: new Date().toISOString(), date_reference: `${annee}-01-01`, nb_etablissements: etabData.size, total_fixes: nbFixes, total_portables: nbPortables, filename: file.name },
etablissements: etablissementsArray,
};
localStorage.setItem(getInventaireStorageKey(annee), JSON.stringify(inventaireData));
localStorage.setItem(`budgetsi_inventaire_import_${annee}`, JSON.stringify({ filename: file.name, date: new Date().toISOString(), size: file.size, nbLignes: rows.length - 1, nbEtablissements: etabData.size, nbFixes, nbPortables, format: useIsiFormat ? 'isi-app' : 'classique' }));
// ── Mettre à jour aussi la liste des établissements (code + nom) ────────────
const existing = loadEtablissements();
const existingMap = new Map(existing.map(e => [e.code, e]));
let nbNouveaux = 0;
let nbMisAJour = 0;
etabData.forEach((info, code) => {
if (existingMap.has(code)) {
const ex = existingMap.get(code)!;
if (!ex.nom && info.nom) {
existingMap.set(code, { ...ex, nom: info.nom });
nbMisAJour++;
// ── Upsert établissements dans la BDD ──────────────────────────────────────
for (const [code, info] of Array.from(etabData.entries())) {
try {
await upsertEtabMutation.mutateAsync({ code, nom: info.nom, groupe: info.groupe ?? null, ville: info.ville ?? null, actif: true });
} catch { /* ignore les erreurs d'upsert établissement */ }
}
} else {
existingMap.set(code, { code, nom: info.nom, groupe: info.groupe, ville: info.ville, actif: true });
nbNouveaux++;
}
});
saveEtablissements(Array.from(existingMap.values()));
// ── Métadonnées d'import ────────────────────────────────────────────────────────
const nbLignes = rows.length - 1;
localStorage.setItem(`budgetsi_inventaire_import_${annee}`, JSON.stringify({
filename: file.name,
date: new Date().toISOString(),
size: file.size,
nbLignes,
nbEtablissements: etabData.size,
nbFixes,
nbPortables,
format: useIsiFormat ? 'isi-app' : 'classique',
}));
const nbNouveaux = result.nbEtablissements;
const details = [
`${nbLignes} poste${nbLignes > 1 ? 's' : ''}`,
`${rows.length - 1} poste${rows.length - 1 > 1 ? 's' : ''}`,
`${etabData.size} établissement${etabData.size > 1 ? 's' : ''} détectés`,
nbNouveaux > 0 ? `${nbNouveaux} nouveau${nbNouveaux > 1 ? 'x' : ''}` : null,
`${nbFixes} fixes`,
`${nbPortables} portables`,
].filter(Boolean).join(' — ');
// Notifier les autres composants (useBudgetData, DsiCapex, Budget2027)
window.dispatchEvent(new CustomEvent('budgetsi_inventaire_updated', { detail: { annee } }));
].join(' — ');
setInventaireStatus('success');
setInventaireMsg(`"${file.name}" importé — ${details}`);
@@ -643,10 +629,19 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
};
}).filter(e => e.code && e.nom);
// Upsert dans la BDD via tRPC
let nbImported = 0;
for (const etab of etabs) {
try {
await upsertEtabMutation.mutateAsync({ code: etab.code, nom: etab.nom, groupe: etab.groupe ?? null, ville: etab.ville ?? null, actif: etab.actif });
nbImported++;
} catch { /* ignore */ }
}
// Aussi sauvegarder en localStorage pour compatibilité
saveEtablissements(etabs);
setEtabStatus('success');
setEtabMsg(`${etabs.length} établissements importés`);
toast.success('Établissements importés', { description: `${etabs.length} établissements chargés` });
setEtabMsg(`${nbImported} établissements importés`);
toast.success('Établissements importés', { description: `${nbImported} établissements chargés` });
} catch {
setEtabStatus('error');
setEtabMsg('Erreur lors de la lecture du fichier');
@@ -701,10 +696,27 @@ export function ImportModal({ open, onClose }: ImportModalProps) {
};
}).filter(u => u.nom && u.email);
// Import dans la BDD via tRPC
const usersPayload = users.map(u => ({
login: u.email.split('@')[0].replace(/[^a-zA-Z0-9._-]/g, '') || `user_${Date.now()}`,
password: 'Itinova2027!',
email: u.email || null,
firstName: u.prenom || null,
lastName: u.nom || null,
role: (u.role === 'lecture' ? 'readonly' : u.role) as 'admin' | 'standard' | 'readonly',
}));
try {
const result = await importUsersMutation.mutateAsync(usersPayload);
setUserStatus('success');
setUserMsg(`${result.created} utilisateurs créés, ${result.skipped} ignorés (login existant)`);
toast.success('Utilisateurs importés', { description: `${result.created} créés — mot de passe par défaut : Itinova2027!` });
} catch (err) {
// Fallback localStorage
saveUtilisateurs(users);
setUserStatus('success');
setUserMsg(`${users.length} utilisateurs importés`);
setUserMsg(`${users.length} utilisateurs importés (local)`);
toast.success('Utilisateurs importés', { description: `${users.length} utilisateurs chargés` });
}
} catch {
setUserStatus('error');
setUserMsg('Erreur lors de la lecture du fichier');

View File

@@ -55,7 +55,11 @@ export function ManusDialog({
<div className="flex flex-col items-center gap-2 p-5 pt-12">
{logo ? (
<div className="w-16 h-16 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] flex items-center justify-center">
<img src={logo} alt="Dialog graphic" className="w-10 h-10 rounded-md" />
<img
src={logo}
alt="Dialog graphic"
className="w-10 h-10 rounded-md"
/>
</div>
) : null}

View File

@@ -1,17 +1,9 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Generate login URL at runtime so redirect URI reflects the current origin.
export const getLoginUrl = () => {
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
const appId = import.meta.env.VITE_APP_ID;
const redirectUri = `${window.location.origin}/api/oauth/callback`;
const state = btoa(redirectUri);
const url = new URL(`${oauthPortalUrl}/app-auth`);
url.searchParams.set("appId", appId);
url.searchParams.set("redirectUri", redirectUri);
url.searchParams.set("state", state);
url.searchParams.set("type", "signIn");
return url.toString();
/**
* Auth locale Itinova — la page de login est /login
* (plus de redirection vers Manus OAuth)
*/
export const getLoginUrl = (_returnPath?: string) => {
return "/login";
};

4
client/src/lib/trpc.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/routers";
export const trpc = createTRPCReact<AppRouter>();

View File

@@ -1,5 +1,61 @@
import { trpc } from "@/lib/trpc";
import { UNAUTHED_ERR_MSG } from '@shared/const';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, TRPCClientError } from "@trpc/client";
import { createRoot } from "react-dom/client";
import superjson from "superjson";
import App from "./App";
import { getLoginUrl } from "./const";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
const queryClient = new QueryClient();
const redirectToLoginIfUnauthorized = (error: unknown) => {
if (!(error instanceof TRPCClientError)) return;
if (typeof window === "undefined") return;
const isUnauthorized = error.message === UNAUTHED_ERR_MSG;
if (!isUnauthorized) return;
window.location.href = getLoginUrl();
};
queryClient.getQueryCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.query.state.error;
redirectToLoginIfUnauthorized(error);
console.error("[API Query Error]", error);
}
});
queryClient.getMutationCache().subscribe(event => {
if (event.type === "updated" && event.action.type === "error") {
const error = event.mutation.state.error;
redirectToLoginIfUnauthorized(error);
console.error("[API Mutation Error]", error);
}
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
fetch(input, init) {
return globalThis.fetch(input, {
...(init ?? {}),
credentials: "include",
});
},
}),
],
});
createRoot(document.getElementById("root")!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
);

File diff suppressed because it is too large Load Diff

218
client/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,218 @@
/**
* Page de connexion locale — Budget SI Itinova
* Branding : logo Itinova en haut, "powered by Santinova" en bas (partie droite)
*/
import { useState } from "react";
import { useLocation } from "wouter";
import { trpc } from "@/lib/trpc";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Eye, EyeOff, Lock, User } from "lucide-react";
const ITINOVA_LOGO = "/manus-storage/Itinova_Logo_Couleurs_f87c931b.jpg";
const SANTINOVA_LOGO = "/manus-storage/logo_santinova_fond_blanc_746ca8e1.webp";
export default function Login() {
const [, navigate] = useLocation();
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const loginMutation = trpc.auth.login.useMutation({
onSuccess: async () => {
await utils.auth.me.invalidate();
navigate("/");
},
onError: (err) => {
setError(err.message || "Identifiants invalides");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!login.trim() || !password.trim()) {
setError("Veuillez remplir tous les champs.");
return;
}
loginMutation.mutate({ login: login.trim(), password });
};
return (
<div className="min-h-screen flex bg-[#f0f4f8]">
{/* Panneau gauche — visuel */}
<div
className="hidden lg:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
style={{
background: "linear-gradient(135deg, #0d1b3e 0%, #1a3a6e 50%, #1e5799 100%)",
}}
>
{/* Motif décoratif */}
<div className="absolute inset-0 opacity-10">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="absolute rounded-full border border-white"
style={{
width: `${(i + 1) * 120}px`,
height: `${(i + 1) * 120}px`,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
/>
))}
</div>
<div className="relative z-10 text-center px-8">
<div className="w-20 h-20 rounded-2xl bg-white/10 backdrop-blur-sm flex items-center justify-center mx-auto mb-6 border border-white/20">
<svg viewBox="0 0 24 24" fill="none" className="w-10 h-10 text-white">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
</svg>
</div>
<h1 className="text-3xl font-bold text-white mb-3" style={{ fontFamily: "Sora, sans-serif" }}>
Budget SI
</h1>
<p className="text-blue-200 text-lg font-medium mb-2">Itinova Group</p>
<p className="text-blue-300 text-sm max-w-xs mx-auto leading-relaxed">
Gestion du budget informatique Renouvellement du parc PC, CAPEX et charges OPEX
</p>
<div className="mt-10 grid grid-cols-3 gap-4 text-center">
{[
{ label: "Établissements", value: "30+" },
{ label: "Exercice", value: "2027" },
{ label: "Modules", value: "4" },
].map((stat) => (
<div key={stat.label} className="bg-white/10 rounded-xl p-3 border border-white/10">
<div className="text-xl font-bold text-white">{stat.value}</div>
<div className="text-xs text-blue-300 mt-0.5">{stat.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Panneau droit — formulaire */}
<div className="flex-1 lg:max-w-md flex flex-col justify-between bg-white shadow-2xl">
{/* Logo Itinova en haut */}
<div className="flex justify-center pt-10 px-8">
<img
src={ITINOVA_LOGO}
alt="Itinova"
className="h-16 object-contain"
/>
</div>
{/* Formulaire centré */}
<div className="flex-1 flex flex-col justify-center px-8 py-6">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900" style={{ fontFamily: "Sora, sans-serif" }}>
Connexion
</h2>
<p className="text-sm text-gray-500 mt-1">
Accédez à votre espace Budget SI
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Login */}
<div className="space-y-1.5">
<Label htmlFor="login" className="text-sm font-medium text-gray-700">
Identifiant
</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
id="login"
type="text"
autoComplete="username"
placeholder="Votre identifiant"
value={login}
onChange={(e) => setLogin(e.target.value)}
className="pl-10 h-11 border-gray-200 focus:border-blue-500 focus:ring-blue-500/20"
disabled={loginMutation.isPending}
/>
</div>
</div>
{/* Mot de passe */}
<div className="space-y-1.5">
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
Mot de passe
</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Votre mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 h-11 border-gray-200 focus:border-blue-500 focus:ring-blue-500/20"
disabled={loginMutation.isPending}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Message d'erreur */}
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5">
<svg className="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{error}
</div>
)}
{/* Bouton connexion */}
<Button
type="submit"
className="w-full h-11 text-sm font-semibold transition-all duration-150 active:scale-[0.98]"
style={{ background: "linear-gradient(135deg, #1a3a6e, #1e5799)" }}
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-4 h-4" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
Connexion en cours
</span>
) : (
"Se connecter"
)}
</Button>
</form>
</div>
{/* Logo Santinova en bas */}
<div className="flex flex-col items-center pb-8 px-8 gap-2">
<div className="w-full h-px bg-gray-100 mb-4" />
<span className="text-xs text-gray-400">powered by</span>
<img
src={SANTINOVA_LOGO}
alt="Santinova Soft"
className="h-8 object-contain opacity-80"
/>
</div>
</div>
</div>
);
}

View File

@@ -33,7 +33,10 @@ export default function NotFound() {
It may have been moved or deleted.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<div
id="not-found-button-group"
className="flex flex-col sm:flex-row gap-3 justify-center"
>
<Button
onClick={handleGoHome}
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"

View File

@@ -1,4 +1,4 @@
// Parametres.tsx — Page de paramétrage global
// Page Paramètres — Budget SI Itinova
// Design: Corporate Modernism — Itinova Budget SI 2027
// Onglets : Paramètres | Établissements | Utilisateurs
@@ -26,20 +26,16 @@ import {
ShieldCheck,
Eye,
User,
Loader2,
KeyRound,
} from 'lucide-react';
import { ANNEES_DISPONIBLES } from '../contexts/AnneeContext';
import { AppSidebar } from '../components/AppSidebar';
import { useParametres, PARAMETRES_DEFAULTS } from '../contexts/ParametresContext';
import { formatEuros } from '../lib/format';
import { toast } from 'sonner';
import {
loadEtablissements,
saveEtablissements,
loadUtilisateurs,
saveUtilisateurs,
type Etablissement,
type Utilisateur,
} from '../components/ImportModal';
import { trpc } from '@/lib/trpc';
import { useAuth } from '../_core/hooks/useAuth';
// ─── Types locaux ─────────────────────────────────────────────────────────────
@@ -132,58 +128,52 @@ function SliderInput({
);
}
// ─── Onglet Établissements ────────────────────────────────────────────────────
// ─── Onglet Établissements (tRPC) ─────────────────────────────────────────────
function OngletEtablissements() {
const [etabs, setEtabs] = useState<Etablissement[]>([]);
const [editId, setEditId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Partial<Etablissement>>({});
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const { data: etabs = [], isLoading, refetch } = trpc.etablissements.list.useQuery();
const upsertMutation = trpc.etablissements.upsert.useMutation({ onSuccess: () => { refetch(); } });
const deleteMutation = trpc.etablissements.delete.useMutation({ onSuccess: () => { refetch(); } });
const [editCode, setEditCode] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<{ nom: string; groupe: string; ville: string }>({ nom: '', groupe: '', ville: '' });
const [showAdd, setShowAdd] = useState(false);
const [newEtab, setNewEtab] = useState<Partial<Etablissement>>({ actif: true });
const [newEtab, setNewEtab] = useState<{ code: string; nom: string; groupe: string; ville: string }>({ code: '', nom: '', groupe: '', ville: '' });
const [search, setSearch] = useState('');
useEffect(() => {
setEtabs(loadEtablissements());
}, []);
const save = (data: Etablissement[]) => {
saveEtablissements(data);
setEtabs(data);
const startEdit = (e: typeof etabs[0]) => {
setEditCode(e.code);
setEditDraft({ nom: e.nom, groupe: e.groupe ?? '', ville: e.ville ?? '' });
};
const toggleActif = (code: string) => {
save(etabs.map(e => e.code === code ? { ...e, actif: !e.actif } : e));
};
const startEdit = (e: Etablissement) => {
setEditId(e.code);
setEditDraft({ ...e });
};
const commitEdit = () => {
if (!editId || !editDraft.nom) return;
save(etabs.map(e => e.code === editId ? { ...e, ...editDraft } as Etablissement : e));
setEditId(null);
const commitEdit = async () => {
if (!editCode || !editDraft.nom) return;
const etab = etabs.find(e => e.code === editCode);
if (!etab) return;
await upsertMutation.mutateAsync({ code: editCode, nom: editDraft.nom, groupe: editDraft.groupe || null, ville: editDraft.ville || null, actif: etab.actif });
setEditCode(null);
toast.success('Établissement modifié');
};
const deleteEtab = (code: string) => {
const toggleActif = async (e: typeof etabs[0]) => {
await upsertMutation.mutateAsync({ code: e.code, nom: e.nom, groupe: e.groupe ?? null, ville: e.ville ?? null, actif: !e.actif });
toast.success(e.actif ? 'Établissement désactivé' : 'Établissement activé');
};
const deleteEtab = async (code: string) => {
if (!confirm(`Supprimer l'établissement ${code} ?`)) return;
save(etabs.filter(e => e.code !== code));
await deleteMutation.mutateAsync({ code });
toast.success('Établissement supprimé');
};
const addEtab = () => {
if (!newEtab.code || !newEtab.nom) {
toast.error('Code et nom obligatoires');
return;
}
if (etabs.some(e => e.code === newEtab.code)) {
toast.error('Ce code existe déjà');
return;
}
save([...etabs, { code: newEtab.code, nom: newEtab.nom, groupe: newEtab.groupe, ville: newEtab.ville, actif: true }]);
setNewEtab({ actif: true });
const addEtab = async () => {
if (!newEtab.code || !newEtab.nom) { toast.error('Code et nom obligatoires'); return; }
if (etabs.some(e => e.code === newEtab.code)) { toast.error('Ce code existe déjà'); return; }
await upsertMutation.mutateAsync({ code: newEtab.code, nom: newEtab.nom, groupe: newEtab.groupe || null, ville: newEtab.ville || null, actif: true });
setNewEtab({ code: '', nom: '', groupe: '', ville: '' });
setShowAdd(false);
toast.success('Établissement ajouté');
};
@@ -211,6 +201,7 @@ function OngletEtablissements() {
onChange={e => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-card focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{isAdmin && (
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors font-medium"
@@ -218,59 +209,43 @@ function OngletEtablissements() {
<Plus className="w-4 h-4" />
Ajouter
</button>
)}
</div>
{/* Formulaire d'ajout */}
{showAdd && (
{showAdd && isAdmin && (
<div className="bg-card border border-emerald-200 rounded-xl p-4 space-y-3">
<p className="font-semibold text-sm text-foreground">Nouvel établissement</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground">Code *</label>
<input
type="text"
value={newEtab.code || ''}
onChange={e => setNewEtab(d => ({ ...d, code: e.target.value }))}
<input type="text" value={newEtab.code} onChange={e => setNewEtab(d => ({ ...d, code: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: ETB001"
/>
placeholder="Ex: ETB001" />
</div>
<div>
<label className="text-xs text-muted-foreground">Nom *</label>
<input
type="text"
value={newEtab.nom || ''}
onChange={e => setNewEtab(d => ({ ...d, nom: e.target.value }))}
<input type="text" value={newEtab.nom} onChange={e => setNewEtab(d => ({ ...d, nom: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Nom de l'établissement"
/>
placeholder="Nom de l'établissement" />
</div>
<div>
<label className="text-xs text-muted-foreground">Groupe</label>
<input
type="text"
value={newEtab.groupe || ''}
onChange={e => setNewEtab(d => ({ ...d, groupe: e.target.value }))}
<input type="text" value={newEtab.groupe} onChange={e => setNewEtab(d => ({ ...d, groupe: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: Itinova"
/>
placeholder="Ex: Itinova" />
</div>
<div>
<label className="text-xs text-muted-foreground">Ville</label>
<input
type="text"
value={newEtab.ville || ''}
onChange={e => setNewEtab(d => ({ ...d, ville: e.target.value }))}
<input type="text" value={newEtab.ville} onChange={e => setNewEtab(d => ({ ...d, ville: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Ex: Lyon"
/>
placeholder="Ex: Lyon" />
</div>
</div>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowAdd(false)} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">
Annuler
</button>
<button onClick={addEtab} className="px-4 py-1.5 text-sm rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 font-medium">
<button onClick={() => setShowAdd(false)} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">Annuler</button>
<button onClick={addEtab} disabled={upsertMutation.isPending} className="px-4 py-1.5 text-sm rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 font-medium flex items-center gap-2">
{upsertMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
Ajouter
</button>
</div>
@@ -278,7 +253,12 @@ function OngletEtablissements() {
)}
{/* Tableau */}
{filtered.length === 0 ? (
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Chargement</span>
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun établissement</p>
@@ -294,28 +274,25 @@ function OngletEtablissements() {
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Groupe</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Ville</th>
<th className="text-center px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actif</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
{isAdmin && <th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>}
</tr>
</thead>
<tbody>
{filtered.map((e, i) => (
<tr key={e.code} className={`border-b border-border last:border-0 ${i % 2 === 0 ? '' : 'bg-muted/20'}`}>
{editId === e.code ? (
{editCode === e.code ? (
<>
<td className="px-4 py-2 font-mono text-xs font-semibold text-blue-700">{e.code}</td>
<td className="px-4 py-2">
<input value={editDraft.code || ''} onChange={ev => setEditDraft(d => ({ ...d, code: ev.target.value }))}
<input value={editDraft.nom} onChange={ev => setEditDraft(d => ({ ...d, nom: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.nom || ''} onChange={ev => setEditDraft(d => ({ ...d, nom: ev.target.value }))}
<input value={editDraft.groupe} onChange={ev => setEditDraft(d => ({ ...d, groupe: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.groupe || ''} onChange={ev => setEditDraft(d => ({ ...d, groupe: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2">
<input value={editDraft.ville || ''} onChange={ev => setEditDraft(d => ({ ...d, ville: ev.target.value }))}
<input value={editDraft.ville} onChange={ev => setEditDraft(d => ({ ...d, ville: ev.target.value }))}
className="w-full px-2 py-1 text-xs border border-border rounded bg-muted/50" />
</td>
<td className="px-4 py-2 text-center"></td>
@@ -324,7 +301,7 @@ function OngletEtablissements() {
<button onClick={commitEdit} className="p-1.5 rounded bg-emerald-100 text-emerald-700 hover:bg-emerald-200">
<Check className="w-3.5 h-3.5" />
</button>
<button onClick={() => setEditId(null)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-muted/70">
<button onClick={() => setEditCode(null)} className="p-1.5 rounded bg-muted text-muted-foreground hover:bg-muted/70">
<X className="w-3.5 h-3.5" />
</button>
</div>
@@ -338,15 +315,17 @@ function OngletEtablissements() {
<td className="px-4 py-2.5 text-muted-foreground text-xs">{e.ville || '—'}</td>
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleActif(e.code)}
onClick={() => isAdmin && toggleActif(e)}
disabled={!isAdmin}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
e.actif ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
} ${!isAdmin ? 'cursor-default' : ''}`}
>
{e.actif ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{e.actif ? 'Actif' : 'Inactif'}
</button>
</td>
{isAdmin && (
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => startEdit(e)} className="p-1.5 rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
@@ -357,6 +336,7 @@ function OngletEtablissements() {
</button>
</div>
</td>
)}
</>
)}
</tr>
@@ -373,52 +353,48 @@ function OngletEtablissements() {
);
}
// ─── Onglet Utilisateurs ──────────────────────────────────────────────────────
// ─── Onglet Utilisateurs (tRPC) ───────────────────────────────────────────────
const ROLE_LABELS: Record<Utilisateur['role'], { label: string; icon: React.ElementType; color: string }> = {
const ROLE_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
admin: { label: 'Administrateur', icon: ShieldCheck, color: 'bg-red-100 text-red-700' },
standard: { label: 'Standard', icon: User, color: 'bg-blue-100 text-blue-700' },
lecture: { label: 'Lecture seule', icon: Eye, color: 'bg-slate-100 text-slate-600' },
readonly: { label: 'Lecture seule', icon: Eye, color: 'bg-slate-100 text-slate-600' },
};
interface UserFormProps {
initial?: Partial<Utilisateur>;
etabs: Etablissement[];
onSave: (u: Omit<Utilisateur, 'id'>) => void;
onCancel: () => void;
title: string;
interface UserFormData {
login: string;
firstName: string;
lastName: string;
email: string;
role: 'admin' | 'standard' | 'readonly';
isActive: boolean;
password: string;
}
function UserForm({ initial, etabs, onSave, onCancel, title }: UserFormProps) {
const [form, setForm] = useState<Partial<Utilisateur>>({
nom: '',
prenom: '',
interface UserFormProps {
initial?: Partial<UserFormData>;
onSave: (u: UserFormData) => void;
onCancel: () => void;
title: string;
isEdit?: boolean;
}
function UserForm({ initial, onSave, onCancel, title, isEdit }: UserFormProps) {
const [form, setForm] = useState<UserFormData>({
login: '',
firstName: '',
lastName: '',
email: '',
role: 'standard',
etablissements: [],
actif: true,
isActive: true,
password: '',
...initial,
});
const toggleEtab = (code: string) => {
setForm(f => ({
...f,
etablissements: f.etablissements?.includes(code)
? f.etablissements.filter(e => e !== code)
: [...(f.etablissements || []), code],
}));
};
const handleSave = () => {
if (!form.nom || !form.email) { toast.error('Nom et email obligatoires'); return; }
onSave({
nom: form.nom!,
prenom: form.prenom || '',
email: form.email!,
role: form.role || 'standard',
etablissements: form.etablissements || [],
actif: form.actif !== false,
});
if (!form.login) { toast.error('Login obligatoire'); return; }
if (!isEdit && !form.password) { toast.error('Mot de passe obligatoire'); return; }
onSave(form);
};
return (
@@ -426,125 +402,117 @@ function UserForm({ initial, etabs, onSave, onCancel, title }: UserFormProps) {
<p className="font-semibold text-sm text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>{title}</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground">Nom *</label>
<input value={form.nom || ''} onChange={e => setForm(f => ({ ...f, nom: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Dupont" />
<label className="text-xs text-muted-foreground">Login *</label>
<input value={form.login} onChange={e => setForm(f => ({ ...f, login: e.target.value }))}
disabled={isEdit}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-60"
placeholder="jean.dupont" />
</div>
<div>
<label className="text-xs text-muted-foreground">{isEdit ? 'Nouveau mot de passe (optionnel)' : 'Mot de passe *'}</label>
<div className="relative mt-1">
<KeyRound className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
className="w-full pl-8 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder={isEdit ? 'Laisser vide = inchangé' : 'Min. 6 caractères'} />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Prénom</label>
<input value={form.prenom || ''} onChange={e => setForm(f => ({ ...f, prenom: e.target.value }))}
<input value={form.firstName} onChange={e => setForm(f => ({ ...f, firstName: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Jean" />
</div>
<div>
<label className="text-xs text-muted-foreground">Nom</label>
<input value={form.lastName} onChange={e => setForm(f => ({ ...f, lastName: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="Dupont" />
</div>
<div className="col-span-2">
<label className="text-xs text-muted-foreground">Email *</label>
<input type="email" value={form.email || ''} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
<label className="text-xs text-muted-foreground">Email</label>
<input type="email" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
placeholder="jean.dupont@itinova.fr" />
</div>
<div>
<label className="text-xs text-muted-foreground">Rôle</label>
<select value={form.role || 'standard'} onChange={e => setForm(f => ({ ...f, role: e.target.value as Utilisateur['role'] }))}
<select value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value as UserFormData['role'] }))}
className="w-full mt-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option value="admin">Administrateur</option>
<option value="standard">Standard</option>
<option value="lecture">Lecture seule</option>
<option value="readonly">Lecture seule</option>
</select>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={form.actif !== false} onChange={e => setForm(f => ({ ...f, actif: e.target.checked }))}
<input type="checkbox" checked={form.isActive} onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))}
className="w-4 h-4 accent-primary" />
<span className="text-sm text-foreground">Compte actif</span>
</label>
</div>
</div>
{/* Rattachement établissements */}
<div>
<label className="text-xs text-muted-foreground block mb-2">
Établissements rattachés ({(form.etablissements || []).length} sélectionné{(form.etablissements || []).length > 1 ? 's' : ''})
</label>
{etabs.length === 0 ? (
<p className="text-xs text-muted-foreground italic">Aucun établissement disponible importez-en via "Imports &amp; Données"</p>
) : (
<div className="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
{etabs.map(e => (
<label key={e.code} className="flex items-center gap-3 px-3 py-2 hover:bg-muted/40 cursor-pointer">
<input
type="checkbox"
checked={(form.etablissements || []).includes(e.code)}
onChange={() => toggleEtab(e.code)}
className="w-4 h-4 accent-primary"
/>
<span className="font-mono text-xs text-blue-700 w-16 flex-shrink-0">{e.code}</span>
<span className="text-sm text-foreground">{e.nom}</span>
{e.groupe && <span className="text-xs text-muted-foreground ml-auto">{e.groupe}</span>}
</label>
))}
</div>
)}
</div>
<div className="flex gap-2 justify-end">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">
Annuler
</button>
<button onClick={handleSave} className="px-4 py-1.5 text-sm rounded-lg bg-violet-600 text-white hover:bg-violet-700 font-medium">
Enregistrer
</button>
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded-lg bg-muted hover:bg-muted/70 text-muted-foreground">Annuler</button>
<button onClick={handleSave} className="px-4 py-1.5 text-sm rounded-lg bg-violet-600 text-white hover:bg-violet-700 font-medium">Enregistrer</button>
</div>
</div>
);
}
function OngletUtilisateurs() {
const [users, setUsers] = useState<Utilisateur[]>([]);
const [etabs, setEtabs] = useState<Etablissement[]>([]);
const utils = trpc.useUtils();
const { data: users = [], isLoading } = trpc.users.list.useQuery();
const createMutation = trpc.users.create.useMutation({ onSuccess: () => utils.users.list.invalidate() });
const updateMutation = trpc.users.update.useMutation({ onSuccess: () => utils.users.list.invalidate() });
const deleteMutation = trpc.users.delete.useMutation({ onSuccess: () => utils.users.list.invalidate() });
const [showAdd, setShowAdd] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editId, setEditId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [expandedUser, setExpandedUser] = useState<string | null>(null);
useEffect(() => {
setUsers(loadUtilisateurs());
setEtabs(loadEtablissements());
}, []);
const save = (data: Utilisateur[]) => {
saveUtilisateurs(data);
setUsers(data);
};
const addUser = (u: Omit<Utilisateur, 'id'>) => {
const newUser: Utilisateur = { ...u, id: `user_${Date.now()}` };
save([...users, newUser]);
const addUser = async (u: UserFormData) => {
try {
await createMutation.mutateAsync({ login: u.login, password: u.password, email: u.email || null, firstName: u.firstName || null, lastName: u.lastName || null, role: u.role });
setShowAdd(false);
toast.success('Utilisateur ajouté');
toast.success('Utilisateur créé');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur';
toast.error(msg);
}
};
const editUser = (u: Omit<Utilisateur, 'id'>) => {
save(users.map(x => x.id === editId ? { ...u, id: editId! } : x));
const editUser = async (u: UserFormData) => {
if (!editId) return;
try {
const data: Parameters<typeof updateMutation.mutateAsync>[0] = { id: editId, email: u.email || null, firstName: u.firstName || null, lastName: u.lastName || null, role: u.role, isActive: u.isActive };
if (u.password) data.password = u.password;
await updateMutation.mutateAsync(data);
setEditId(null);
toast.success('Utilisateur modifié');
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur';
toast.error(msg);
}
};
const deleteUser = (id: string) => {
const deleteUser = async (id: number) => {
if (!confirm('Supprimer cet utilisateur ?')) return;
save(users.filter(u => u.id !== id));
await deleteMutation.mutateAsync({ id });
toast.success('Utilisateur supprimé');
};
const toggleActif = (id: string) => {
save(users.map(u => u.id === id ? { ...u, actif: !u.actif } : u));
const toggleActif = async (u: typeof users[0]) => {
await updateMutation.mutateAsync({ id: u.id, isActive: !u.isActive });
toast.success(u.isActive ? 'Compte désactivé' : 'Compte activé');
};
const filtered = users.filter(u =>
!search ||
u.nom.toLowerCase().includes(search.toLowerCase()) ||
u.prenom.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
(u.login || '').toLowerCase().includes(search.toLowerCase()) ||
(u.firstName || '').toLowerCase().includes(search.toLowerCase()) ||
(u.lastName || '').toLowerCase().includes(search.toLowerCase()) ||
(u.email || '').toLowerCase().includes(search.toLowerCase())
);
const editingUser = editId ? users.find(u => u.id === editId) : undefined;
@@ -554,53 +522,44 @@ function OngletUtilisateurs() {
<div className="flex items-start gap-3 p-4 bg-violet-50 border border-violet-200 rounded-xl">
<Info className="w-4 h-4 text-violet-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-violet-800">
Gérez les utilisateurs et leurs rattachements aux établissements.
Un utilisateur peut être rattaché à <strong>plusieurs établissements</strong>.
Les rôles disponibles sont : <strong>Administrateur</strong>, <strong>Standard</strong> et <strong>Lecture seule</strong>.
Gérez les utilisateurs de l'application. Les rôles disponibles sont : <strong>Administrateur</strong> (accès complet), <strong>Standard</strong> (lecture/écriture) et <strong>Lecture seule</strong>.
</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
placeholder="Rechercher par nom, prénom ou email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-card focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
onClick={() => { setShowAdd(true); setEditId(null); }}
className="flex items-center gap-2 px-4 py-2 text-sm bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors font-medium"
>
<input type="text" placeholder="Rechercher par login, nom ou email…" value={search} onChange={e => setSearch(e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-card focus:outline-none focus:ring-2 focus:ring-primary/30" />
<button onClick={() => { setShowAdd(true); setEditId(null); }}
className="flex items-center gap-2 px-4 py-2 text-sm bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors font-medium">
<Plus className="w-4 h-4" />
Ajouter
</button>
</div>
{showAdd && (
<UserForm
etabs={etabs}
onSave={addUser}
onCancel={() => setShowAdd(false)}
title="Nouvel utilisateur"
/>
<UserForm onSave={addUser} onCancel={() => setShowAdd(false)} title="Nouvel utilisateur" />
)}
{editId && editingUser && (
<UserForm
initial={editingUser}
etabs={etabs}
initial={{ login: editingUser.login, firstName: editingUser.firstName ?? '', lastName: editingUser.lastName ?? '', email: editingUser.email ?? '', role: editingUser.role as 'admin' | 'standard' | 'readonly', isActive: editingUser.isActive, password: '' }}
onSave={editUser}
onCancel={() => setEditId(null)}
title={`Modifier ${editingUser.prenom} ${editingUser.nom}`}
title={`Modifier ${editingUser.firstName ?? ''} ${editingUser.lastName ?? editingUser.login}`}
isEdit
/>
)}
{filtered.length === 0 ? (
{isLoading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground gap-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Chargement…</span>
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Users className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun utilisateur</p>
<p className="text-sm mt-1">Importez une liste via "Imports &amp; Données" ou ajoutez manuellement.</p>
<p className="text-sm mt-1">Ajoutez des utilisateurs manuellement.</p>
</div>
) : (
<div className="bg-card border border-border rounded-xl overflow-hidden">
@@ -608,56 +567,37 @@ function OngletUtilisateurs() {
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Utilisateur</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Login</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Email</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Rôle</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Établissements</th>
<th className="text-center px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Statut</th>
<th className="text-right px-4 py-3 font-semibold text-muted-foreground text-xs uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map((u, i) => {
const roleInfo = ROLE_LABELS[u.role];
const roleInfo = ROLE_LABELS[u.role] ?? ROLE_LABELS.standard;
const RoleIcon = roleInfo.icon;
const isExpanded = expandedUser === u.id;
return (
<tr key={u.id} className={`border-b border-border last:border-0 ${i % 2 === 0 ? '' : 'bg-muted/20'}`}>
<td className="px-4 py-2.5">
<p className="font-medium text-foreground">{u.prenom} {u.nom}</p>
<p className="font-medium text-foreground">{u.firstName} {u.lastName}</p>
</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{u.email}</td>
<td className="px-4 py-2.5 font-mono text-xs text-blue-700">{u.login}</td>
<td className="px-4 py-2.5 text-muted-foreground text-xs">{u.email || ''}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${roleInfo.color}`}>
<RoleIcon className="w-3 h-3" />
{roleInfo.label}
</span>
</td>
<td className="px-4 py-2.5">
{u.etablissements.length === 0 ? (
<span className="text-xs text-muted-foreground italic">Aucun</span>
) : (
<button
onClick={() => setExpandedUser(isExpanded ? null : u.id)}
className="text-xs text-blue-600 hover:underline"
>
{u.etablissements.length} établissement{u.etablissements.length > 1 ? 's' : ''}
{isExpanded && (
<span className="block mt-1 text-muted-foreground font-normal text-left">
{u.etablissements.join(', ')}
</span>
)}
</button>
)}
</td>
<td className="px-4 py-2.5 text-center">
<button
onClick={() => toggleActif(u.id)}
<button onClick={() => toggleActif(u)}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.actif ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}
>
{u.actif ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{u.actif ? 'Actif' : 'Inactif'}
u.isActive ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'bg-muted text-muted-foreground hover:bg-muted/70'
}`}>
{u.isActive ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
{u.isActive ? 'Actif' : 'Inactif'}
</button>
</td>
<td className="px-4 py-2.5 text-right">
@@ -817,7 +757,7 @@ export default function Parametres() {
<Info className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" />
<p>
Les modifications sont appliquées <strong>immédiatement</strong> après enregistrement.
Tous les budgets des 60 établissements sont recalculés automatiquement.
Tous les budgets des établissements sont recalculés automatiquement.
Les valeurs sont sauvegardées dans votre navigateur.
</p>
</div>
@@ -883,46 +823,27 @@ export default function Parametres() {
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border">
<BarChart3 className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Paramètres en cours d'édition</h2>
<h2 className="font-semibold text-foreground" style={{ fontFamily: 'Sora, sans-serif' }}>Aperçu des paramètres actuels</h2>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Seuil PC Fixes', value: `${draft.seuilFixesAns} ans`, changed: draft.seuilFixesAns !== parametres.seuilFixesAns },
{ label: 'Seuil PC Portables', value: `${draft.seuilPortablesAns} ans`, changed: draft.seuilPortablesAns !== parametres.seuilPortablesAns },
{ label: 'Coût fixe unitaire', value: formatEuros(draft.coutFixe), changed: draft.coutFixe !== parametres.coutFixe },
{ label: 'Coût portable unitaire', value: formatEuros(draft.coutPortable), changed: draft.coutPortable !== parametres.coutPortable },
{ label: 'Année par défaut', value: `${draft.anneeDefaut}`, changed: draft.anneeDefaut !== parametres.anneeDefaut },
].map((item) => (
<div key={item.label} className={`rounded-lg border px-4 py-3 transition-colors ${item.changed ? 'border-orange-300 bg-orange-50' : 'border-border bg-card'}`}>
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className={`font-bold mt-0.5 ${item.changed ? 'text-orange-700' : 'text-foreground'}`}>
{item.value}
{item.changed && <span className="ml-2 text-[10px] font-normal text-orange-500 uppercase tracking-wide">modifié</span>}
</p>
{ label: 'Seuil PC Fixes', value: `${parametres.seuilFixesAns} ans`, color: 'text-blue-700' },
{ label: 'Seuil PC Portables', value: `${parametres.seuilPortablesAns} ans`, color: 'text-orange-700' },
{ label: 'Coût PC Fixe', value: formatEuros(parametres.coutFixe), color: 'text-blue-700' },
{ label: 'Coût PC Portable', value: formatEuros(parametres.coutPortable), color: 'text-orange-700' },
].map(item => (
<div key={item.label} className="bg-muted/40 rounded-xl p-3 text-center">
<p className={`text-lg font-bold tabular-nums ${item.color}`}>{item.value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
</div>
))}
</div>
{hasChanges && (
<p className="text-sm text-orange-600 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" />
Des modifications non enregistrées sont en attente. Cliquez sur <strong>Enregistrer</strong> pour les appliquer.
</p>
)}
</section>
</div>
)}
{activeTab === 'etablissements' && (
<div className="max-w-4xl">
<OngletEtablissements />
</div>
)}
{activeTab === 'utilisateurs' && (
<div className="max-w-5xl">
<OngletUtilisateurs />
</div>
)}
{activeTab === 'etablissements' && <OngletEtablissements />}
{activeTab === 'utilisateurs' && <OngletUtilisateurs />}
</div>
</main>
</div>

15
drizzle.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "drizzle-kit";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required to run drizzle commands");
}
export default defineConfig({
schema: "./drizzle/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: connectionString,
},
});

123
drizzle/0000_fat_falcon.sql Normal file
View File

@@ -0,0 +1,123 @@
CREATE TABLE `capex_lignes` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`etablissementCode` varchar(50) NOT NULL,
`cle` varchar(100) NOT NULL,
`montant` decimal(12,2),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `capex_lignes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`code` varchar(50) NOT NULL,
`nom` varchar(255) NOT NULL,
`groupe` varchar(100),
`ville` varchar(100),
`actif` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `etablissements_id` PRIMARY KEY(`id`),
CONSTRAINT `etablissements_code_unique` UNIQUE(`code`)
);
--> statement-breakpoint
CREATE TABLE `inventaire_meta` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`filename` varchar(255),
`dateImport` timestamp NOT NULL DEFAULT (now()),
`nbEtablissements` int DEFAULT 0,
`nbFixes` int DEFAULT 0,
`nbPortables` int DEFAULT 0,
CONSTRAINT `inventaire_meta_id` PRIMARY KEY(`id`),
CONSTRAINT `inventaire_meta_annee_unique` UNIQUE(`annee`)
);
--> statement-breakpoint
CREATE TABLE `inventaire_postes` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`etablissementCode` varchar(50) NOT NULL,
`libelle` varchar(255),
`typePoste` enum('fixe','portable') NOT NULL,
`dateRef` varchar(20),
`ageAns` decimal(5,2),
`modele` varchar(255),
`fabricant` varchar(100),
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `inventaire_postes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `opex_montants_etab` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`etablissementCode` varchar(50) NOT NULL,
`libellePoste` varchar(255) NOT NULL,
`montant` decimal(12,2),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `opex_montants_etab_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `opex_postes` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`colIdx` int NOT NULL,
`libelle` varchar(255) NOT NULL,
`libelleCourt` varchar(100),
`libelleDetail` varchar(255),
`fournisseur` varchar(255),
`categorie` varchar(100),
`type` varchar(100),
`facturation` varchar(100),
`modeVentilation` varchar(50) DEFAULT 'Prorata C 6',
`compte` varchar(50),
`detail` text,
`budgetN1` decimal(12,2),
`montant` decimal(12,2),
`isCustom` boolean DEFAULT false,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `opex_postes_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `opex_validated` (
`id` int AUTO_INCREMENT NOT NULL,
`annee` int NOT NULL,
`validatedAt` timestamp NOT NULL DEFAULT (now()),
`validatedBy` int,
CONSTRAINT `opex_validated_id` PRIMARY KEY(`id`),
CONSTRAINT `opex_validated_annee_unique` UNIQUE(`annee`)
);
--> statement-breakpoint
CREATE TABLE `parametres_app` (
`id` int AUTO_INCREMENT NOT NULL,
`cle` varchar(100) NOT NULL,
`valeur` text,
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `parametres_app_id` PRIMARY KEY(`id`),
CONSTRAINT `parametres_app_cle_unique` UNIQUE(`cle`)
);
--> statement-breakpoint
CREATE TABLE `user_etablissements` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`etablissementCode` varchar(50) NOT NULL,
CONSTRAINT `user_etablissements_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` int AUTO_INCREMENT NOT NULL,
`login` varchar(255) NOT NULL,
`email` varchar(320),
`passwordHash` varchar(255) NOT NULL,
`firstName` varchar(100),
`lastName` varchar(100),
`role` enum('admin','standard','readonly') NOT NULL DEFAULT 'standard',
`isActive` boolean NOT NULL DEFAULT true,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`lastSignedIn` timestamp,
CONSTRAINT `users_id` PRIMARY KEY(`id`),
CONSTRAINT `users_login_unique` UNIQUE(`login`)
);

View File

@@ -0,0 +1,804 @@
{
"version": "5",
"dialect": "mysql",
"id": "973efedb-ae3f-4e9d-b1e4-fb7ab4115377",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"capex_lignes": {
"name": "capex_lignes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"capex_lignes_id": {
"name": "capex_lignes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"etablissements": {
"name": "etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"code": {
"name": "code",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"groupe": {
"name": "groupe",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ville": {
"name": "ville",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"etablissements_id": {
"name": "etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"etablissements_code_unique": {
"name": "etablissements_code_unique",
"columns": [
"code"
]
}
},
"checkConstraint": {}
},
"inventaire_meta": {
"name": "inventaire_meta",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dateImport": {
"name": "dateImport",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"nbEtablissements": {
"name": "nbEtablissements",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbFixes": {
"name": "nbFixes",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"nbPortables": {
"name": "nbPortables",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_meta_id": {
"name": "inventaire_meta_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"inventaire_meta_annee_unique": {
"name": "inventaire_meta_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"inventaire_postes": {
"name": "inventaire_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"typePoste": {
"name": "typePoste",
"type": "enum('fixe','portable')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateRef": {
"name": "dateRef",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ageAns": {
"name": "ageAns",
"type": "decimal(5,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modele": {
"name": "modele",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fabricant": {
"name": "fabricant",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inventaire_postes_id": {
"name": "inventaire_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_montants_etab": {
"name": "opex_montants_etab",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libellePoste": {
"name": "libellePoste",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_montants_etab_id": {
"name": "opex_montants_etab_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_postes": {
"name": "opex_postes",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"colIdx": {
"name": "colIdx",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelle": {
"name": "libelle",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"libelleCourt": {
"name": "libelleCourt",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"libelleDetail": {
"name": "libelleDetail",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fournisseur": {
"name": "fournisseur",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categorie": {
"name": "categorie",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"facturation": {
"name": "facturation",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"modeVentilation": {
"name": "modeVentilation",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'Prorata C 6'"
},
"compte": {
"name": "compte",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"budgetN1": {
"name": "budgetN1",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"montant": {
"name": "montant",
"type": "decimal(12,2)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"isCustom": {
"name": "isCustom",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_postes_id": {
"name": "opex_postes_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"opex_validated": {
"name": "opex_validated",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"annee": {
"name": "annee",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"validatedAt": {
"name": "validatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"validatedBy": {
"name": "validatedBy",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"opex_validated_id": {
"name": "opex_validated_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"opex_validated_annee_unique": {
"name": "opex_validated_annee_unique",
"columns": [
"annee"
]
}
},
"checkConstraint": {}
},
"parametres_app": {
"name": "parametres_app",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"cle": {
"name": "cle",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"valeur": {
"name": "valeur",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"parametres_app_id": {
"name": "parametres_app_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"parametres_app_cle_unique": {
"name": "parametres_app_cle_unique",
"columns": [
"cle"
]
}
},
"checkConstraint": {}
},
"user_etablissements": {
"name": "user_etablissements",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"etablissementCode": {
"name": "etablissementCode",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_etablissements_id": {
"name": "user_etablissements_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"login": {
"name": "login",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"firstName": {
"name": "firstName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lastName": {
"name": "lastName",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('admin','standard','readonly')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'standard'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_login_unique": {
"name": "users_login_unique",
"columns": [
"login"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1781092850222,
"tag": "0000_fat_falcon",
"breakpoints": true
}
]
}

View File

1
drizzle/relations.ts Normal file
View File

@@ -0,0 +1 @@
import {} from "./schema";

162
drizzle/schema.ts Normal file
View File

@@ -0,0 +1,162 @@
import {
boolean,
decimal,
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
// ─────────────────────────────────────────────────────────────────────────────
// USERS — Auth locale (email/password), 3 profils : admin | standard | readonly
// ─────────────────────────────────────────────────────────────────────────────
export const users = mysqlTable("users", {
id: int("id").autoincrement().primaryKey(),
/** Identifiant de connexion (email ou login court comme adminItinova) */
login: varchar("login", { length: 255 }).notNull().unique(),
email: varchar("email", { length: 320 }),
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
firstName: varchar("firstName", { length: 100 }),
lastName: varchar("lastName", { length: 100 }),
role: mysqlEnum("role", ["admin", "standard", "readonly"]).default("standard").notNull(),
isActive: boolean("isActive").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn"),
});
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// ─────────────────────────────────────────────────────────────────────────────
// ÉTABLISSEMENTS
// ─────────────────────────────────────────────────────────────────────────────
export const etablissements = mysqlTable("etablissements", {
id: int("id").autoincrement().primaryKey(),
code: varchar("code", { length: 50 }).notNull().unique(),
nom: varchar("nom", { length: 255 }).notNull(),
groupe: varchar("groupe", { length: 100 }),
ville: varchar("ville", { length: 100 }),
actif: boolean("actif").default(true).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type Etablissement = typeof etablissements.$inferSelect;
export type InsertEtablissement = typeof etablissements.$inferInsert;
// Rattachement utilisateur ↔ établissements (pour profil standard/readonly)
export const userEtablissements = mysqlTable("user_etablissements", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
});
// ─────────────────────────────────────────────────────────────────────────────
// PARAMÈTRES APPLICATION
// ─────────────────────────────────────────────────────────────────────────────
export const parametresApp = mysqlTable("parametres_app", {
id: int("id").autoincrement().primaryKey(),
cle: varchar("cle", { length: 100 }).notNull().unique(),
valeur: text("valeur"),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
// ─────────────────────────────────────────────────────────────────────────────
// OPEX — Postes budgétaires
// ─────────────────────────────────────────────────────────────────────────────
export const opexPostes = mysqlTable("opex_postes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
colIdx: int("colIdx").notNull(),
libelle: varchar("libelle", { length: 255 }).notNull(),
libelleCourt: varchar("libelleCourt", { length: 100 }),
libelleDetail: varchar("libelleDetail", { length: 255 }),
fournisseur: varchar("fournisseur", { length: 255 }),
categorie: varchar("categorie", { length: 100 }),
type: varchar("type", { length: 100 }),
facturation: varchar("facturation", { length: 100 }),
modeVentilation: varchar("modeVentilation", { length: 50 }).default("Prorata C 6"),
compte: varchar("compte", { length: 50 }),
detail: text("detail"),
budgetN1: decimal("budgetN1", { precision: 12, scale: 2 }),
montant: decimal("montant", { precision: 12, scale: 2 }),
isCustom: boolean("isCustom").default(false),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type OpexPoste = typeof opexPostes.$inferSelect;
export type InsertOpexPoste = typeof opexPostes.$inferInsert;
// Overrides manuels par établissement × poste × année
export const opexMontantsEtab = mysqlTable("opex_montants_etab", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
libellePoste: varchar("libellePoste", { length: 255 }).notNull(),
montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type OpexMontantEtab = typeof opexMontantsEtab.$inferSelect;
export type InsertOpexMontantEtab = typeof opexMontantsEtab.$inferInsert;
// Validation définitive par année
export const opexValidated = mysqlTable("opex_validated", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull().unique(),
validatedAt: timestamp("validatedAt").defaultNow().notNull(),
validatedBy: int("validatedBy"),
});
// ─────────────────────────────────────────────────────────────────────────────
// INVENTAIRE PC
// ─────────────────────────────────────────────────────────────────────────────
export const inventairePostes = mysqlTable("inventaire_postes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
libelle: varchar("libelle", { length: 255 }),
typePoste: mysqlEnum("typePoste", ["fixe", "portable"]).notNull(),
dateRef: varchar("dateRef", { length: 20 }),
ageAns: decimal("ageAns", { precision: 5, scale: 2 }),
modele: varchar("modele", { length: 255 }),
fabricant: varchar("fabricant", { length: 100 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type InventairePoste = typeof inventairePostes.$inferSelect;
export type InsertInventairePoste = typeof inventairePostes.$inferInsert;
// Métadonnées d'import inventaire
export const inventaireMeta = mysqlTable("inventaire_meta", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull().unique(),
filename: varchar("filename", { length: 255 }),
dateImport: timestamp("dateImport").defaultNow().notNull(),
nbEtablissements: int("nbEtablissements").default(0),
nbFixes: int("nbFixes").default(0),
nbPortables: int("nbPortables").default(0),
});
// ─────────────────────────────────────────────────────────────────────────────
// CAPEX — Lignes budgétaires par établissement
// ─────────────────────────────────────────────────────────────────────────────
export const capexLignes = mysqlTable("capex_lignes", {
id: int("id").autoincrement().primaryKey(),
annee: int("annee").notNull(),
etablissementCode: varchar("etablissementCode", { length: 50 }).notNull(),
cle: varchar("cle", { length: 100 }).notNull(),
// cles possibles: renouvellement_2027 | machines_supplementaires | appel_malade
// | telephonie | wifi | video_surveillance | copieurs | visio | autres | commentaires
montant: decimal("montant", { precision: 12, scale: 2 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type CapexLigne = typeof capexLignes.$inferSelect;
export type InsertCapexLigne = typeof capexLignes.$inferInsert;

View File

@@ -4,14 +4,17 @@
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite --host",
"build": "vite build && esbuild server/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"dev": "NODE_ENV=development tsx watch server/_core/index.ts",
"build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"preview": "vite preview --host",
"check": "tsc --noEmit",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "vitest run",
"db:push": "drizzle-kit generate && drizzle-kit migrate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -39,15 +42,26 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"axios": "^1.12.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie": "^1.0.2",
"date-fns": "^4.1.0",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.44.5",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"framer-motion": "^12.23.22",
"input-otp": "^1.4.2",
"jose": "6.1.0",
"lucide-react": "^0.453.0",
"mysql2": "^3.15.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"react": "^19.2.1",
@@ -58,6 +72,7 @@
"recharts": "^2.15.2",
"sonner": "^2.0.7",
"streamdown": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
@@ -69,6 +84,7 @@
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/bcryptjs": "^3.0.0",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
@@ -77,6 +93,7 @@
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",
"pnpm": "^10.15.1",
"postcss": "^8.4.47",
@@ -84,7 +101,7 @@
"tailwindcss": "^4.1.14",
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.6.3",
"typescript": "5.9.3",
"vite": "^7.1.7",
"vite-plugin-manus-runtime": "^0.0.57",
"vitest": "^2.1.4"

1379
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
# Periodic Updates — Reference
Scope: any recurring or scheduled work for this site (digests, refreshes, cleanups, end-user-defined schedules, periodic notifications).
Forbidden: `setInterval`, `node-cron`, or any in-process timer. Cloud Run terminates idle instances; in-process timers will not survive.
---
## 1. Pick the right cron type
Two flavors. The difference is what runs at trigger time:
- **Heartbeat (HTTP cron).** Platform POSTs directly to `/api/scheduled/*` on this site. Your handler runs and returns. No agent spawned — if the handler needs an LLM call, do it inline via the site's own LLM SDK (`server/_core/llm.ts`).
- **AGENT cron.** Platform spawns a fresh, isolated Manus session that runs the prompt you wrote at create time. The agent has the full Manus toolchain — browser, file system, shell, image gen, deep research — but **no** session history, DB, source code, or credentials beyond the two env vars `$SCHEDULED_TASK_ENDPOINT_BASE` / `$SCHEDULED_TASK_COOKIE`. If your prompt tells it to, it `curl`s back to `/api/scheduled/*` on this site at the end.
Decision: AGENT cron only if the trigger genuinely needs **agentic capabilities** — i.e. tool use beyond a single LLM call: web browsing, file manipulation, shell, deep research, multi-step planning, etc. A one-shot LLM completion belongs inline in a Heartbeat handler. Do not attempt to replicate complex agentic flows using website-inbuilt capabilities, if it could be outsourced to AGENT cron. End-user-defined schedules (UI on this site lets a user pick when X runs) are **always** Heartbeat — see §3.
Both flavors hit the **same** `/api/scheduled/*` endpoint with the **same** auth shape: `sdk.authenticateRequest(req)` returns `user.isCron === true` with `user.taskUid` set.
---
## 2. Facts (apply to BOTH flavors)
1. Callback path **MUST** start with `/api/scheduled/`. Forge rejects everything else.
2. Add a `schedule_cron_task_uid varchar(65)` column (indexed, nullable) to whatever business row owns the job. **Update / delete / look up the business row by `task_uid`, never by `name` or by anything from `req.body`.**
3. The site **must be deployed** before scheduling — bizserver POSTs the production URL, dev sandboxes are unreachable. For any changes to the callback handler, or creation of callback handler, ALWAYS follow this WORKFLOW: save a checkpoint, ask the user to Deploy, before any schedule actions take place.
4. Wrap handler logic in try/catch and JSON-encode the error on 500 — the platform's Investigate flow surfaces it verbatim.
5. Cron is **6-field** (with seconds): `sec min hour dom mon dow`, UTC, min interval 60s. Use `0` for the seconds field — e.g. `0 0 9 * * *` is daily 09:00 UTC.
6. Handlers must be **idempotent**. The platform retries `5xx` and `429` up to 3 times (3s → 1m backoff). Other `4xx` are treated as business failures and not retried.
7. Handler timeout is 2 minutes per call.
---
## 3. End-user-driven Heartbeat (tRPC create + `/api/scheduled/*` callback)
Required pieces (assumes the `schedule_cron_task_uid` column from Facts #2 is already on the business row):
1. tRPC mutation that calls `createHeartbeatJob(...)` and persists the returned `taskUid` to that column.
2. Express handler at `/api/scheduled/<name>` that authenticates via `sdk.authenticateRequest`, looks up the business row by `taskUid`, runs the work.
3. Explicit `app.post("/api/scheduled/<name>", handler)` in `server/_core/index.ts` before the Vite/static fallthrough — `/api/scheduled/*` is not auto-registered.
A one-time setup walkthrough — do all three steps in one pass; they're a single workflow, not independent options.
**Step 1 — tRPC mutation calls the SDK to create the cron and persists `task_uid` on the business row.** For update / delete / pause / resume, look up `scheduleCronTaskUid` first then call `updateHeartbeatJob(taskUid, patch, sessionToken)` / `deleteHeartbeatJob(taskUid, sessionToken)``patch.enable=false` pauses, `true` resumes, omit to leave unchanged. All SDK functions throw `TRPCError`; let trpc bubble.
```ts
import { parse as parseCookie } from "cookie";
import { COOKIE_NAME } from "@shared/const";
import { createHeartbeatJob } from "../_core/heartbeat";
const sessionToken = parseCookie(ctx.req.headers.cookie ?? "")[COOKIE_NAME] ?? "";
const job = await createHeartbeatJob({
name: `marketing-${campaign.id}`, // unique within (project, owner)
cron: input.cron, // 6-field "sec min hour dom mon dow"
path: "/api/scheduled/sendMarketing",
payload: { campaignId: campaign.id },
description: `Daily 9am send for ${campaign.name}`,
}, sessionToken);
await db.update(campaigns)
.set({ scheduleCronTaskUid: job.taskUid })
.where(eq(campaigns.id, campaign.id));
```
`sessionToken` MUST be the decoded `app_session_id` cookie value, not the raw `Cookie` header — forge attributes the cron to the requesting end-user via this token. Apply your normal ownership check on the business row before scheduling.
**Step 2 — Express handler at `/api/scheduled/<name>` runs on each trigger.** Look up the business row by `user.taskUid` (never by `req.body` fields — the body is attacker-controllable; `taskUid` is set by the cron system). Wrap with try/catch and on 500 return `{ error, stack, context: { url, taskUid }, timestamp }` so the platform Investigate flow can surface it.
```ts
const user = await sdk.authenticateRequest(req);
if (!user.isCron || !user.taskUid) return res.status(403).json({ error: "cron-only" });
const campaign = (await db.select().from(campaigns)
.where(eq(campaigns.scheduleCronTaskUid, user.taskUid)).limit(1))[0];
if (!campaign) return res.json({ ok: true, skipped: "orphan" }); // 2xx so forge stops retrying
await sendCampaignEmails(campaign);
res.json({ ok: true });
```
**Step 3 — Mount the handler** in `server/_core/index.ts` before the Vite / static fallthrough — `/api/scheduled/*` is not auto-registered.
```ts
app.post("/api/scheduled/sendMarketing", sendMarketingHandler);
```
After deploy, end-users create their own crons via your tRPC mutation. As the project owner, you can also inspect / pause / resume / view logs for any end-user's cron from the sandbox terminal — `manus-heartbeat list --user-id u_xxx` and friends; see §5b. End-users can never see another user's cron through your tRPC SDK.
---
## 4. Variants — when the trigger isn't an end-user
Same callback handler, same `/api/scheduled/*` URL, same `user.isCron` check, **same Manus-platform-managed cron lifecycle** (cron persistence and triggering live entirely on the platform, independent of any sandbox session). Only the **creator** changes.
### 4a. Project-level Heartbeat (no end-user)
For crons your end-users never see (nightly DB cleanup, daily digest to admins, hourly external-API ping) — the cron is owned by the project owner identity. Create via the sandbox CLI (see §5b for the full subcommand list):
```bash
manus-heartbeat create \
--name nightly-cleanup \
--cron "0 0 3 * * *" \
--path /api/scheduled/cleanup \
--description "Nightly expired-row cleanup"
```
The created cron lives on the Manus platform, not in this sandbox: it survives sandbox hibernation/teardown and keeps firing as long as the deployed site is reachable. Any future Manus session can `manus-heartbeat list` / `update` / `pause` / `delete` it — the cron is bound to the project owner, not to the build session that created it.
Persist the returned `task_uid` somewhere durable (admin DB row / config) if you'll need to update/delete it later — `manus-heartbeat list` can also recover it.
### 4b. AGENT cron — when the trigger needs agentic capabilities
Created via the `schedule` tool inside this Manus session (not from site code). Each trigger spawns a **fresh, isolated** Manus agent — no session history, no source/DB/credentials, no skills. The only knowledge transfer is whatever you write into the cron prompt.
Use it when the work genuinely needs agent intelligence (deep research, content composition, image gen). Tell it WHAT to do, not HOW — it's a real agent, not a workflow runner.
If the AGENT cron needs to write back to **this** site, the only path in is the site's HTTP API:
1. Add `/api/scheduled/<name>` as in §3 Step 2 (same auth, same handler shape).
2. Save a checkpoint and ask the user to Deploy.
3. In the cron prompt, instruct the agent to POST via `curl` (not python libs) using two auto-injected envs:
- `$SCHEDULED_TASK_ENDPOINT_BASE` — base URL of this site
- `$SCHEDULED_TASK_COOKIE` — the raw `app_session_id` JWT value
```sh
curl -X POST "$SCHEDULED_TASK_ENDPOINT_BASE/api/scheduled/news" \
-H "Content-Type: application/json" \
-H "Cookie: app_session_id=$SCHEDULED_TASK_COOKIE" \
-d '{"title":"…","body":"…"}'
```
The endpoint receives `user.isCron === true` exactly like Heartbeat — no special-casing needed.
### 4c. Owner UI on manus.im (NOT something you build)
The Manus dashboard surfaces ALL crons in the project (both end-user-driven and agent-driven), with execution history, pause/resume, edit, Run Now, and Investigate. Owners can't *create* crons there — only via §3 / §4a / §4b. Mention this to the user when they ask "how do I see/manage all my crons".
---
## 5. References
### 5a. Site SDK — `server/_core/heartbeat.ts`
```ts
type HeartbeatJob = {
name: string; // unique within (project, owner)
cron: string; // 6-field UTC "sec min hour dom mon dow"
path: string; // must start with /api/scheduled/
method?: "POST" | "PUT"; // default POST
payload?: unknown; // JSON body sent on every trigger
description?: string;
};
type HeartbeatJobUpdate = Partial<Omit<HeartbeatJob, "name">> & {
enable?: boolean; // true=resume, false=pause, omit=unchanged
};
type HeartbeatJobInfo = {
taskUid: string; name: string; userId: string; description: string;
cronExpression: string; callbackPath: string; callbackMethod: string;
callbackPayload: string; isEnable: boolean;
createdAt?: string | null;
lastExecutedAt?: string | null;
nextExecutionAt?: string | null;
};
createHeartbeatJob(job: HeartbeatJob, userSession: string)
: Promise<{ taskUid: string; nextExecutionAt?: string | null }>;
updateHeartbeatJob(taskUid: string, patch: HeartbeatJobUpdate, userSession: string)
: Promise<{ nextExecutionAt?: string | null }>;
deleteHeartbeatJob(taskUid: string, userSession: string)
: Promise<void>;
listHeartbeatJobs(userSession: string, pagination?: { page?: number; pageSize?: number })
: Promise<{ total: number; actorUserId: string; jobs: HeartbeatJobInfo[] }>;
```
`userSession` is the **decoded `app_session_id` cookie value**, NOT the raw Cookie header. All four functions throw `TRPCError` (UNAUTHORIZED / NOT_FOUND / TOO_MANY_REQUESTS / FORBIDDEN / BAD_REQUEST) — let trpc bubble them.
### 5b. Sandbox CLI — `manus-heartbeat`
In-sandbox tool that hits the same backend as the SDK in §5a but with project owner identity (no end-user cookie). `BUILT_IN_FORGE_API_*` envs are pre-set on PATH; run from a sandbox terminal during this session.
The table below is just an index — `manus-heartbeat <cmd> --help` is canonical for full flag lists and examples.
| Command | What |
| --- | --- |
| `create` | Create a cron under the project owner identity (§4a). Returns `task_uid` — persist it. |
| `update` | Mutate a cron located by `--task-uid`. `--enable=false` pauses, `--enable=true` resumes; can also change cron expression / path / payload. |
| `delete` | Remove a cron located by `--task-uid`. |
| `list` | List crons. Default = owner's own; `--user-id u_xxx` inspects an end-user's (debugging §3 crons). |
| `logs` | Recent execution history for one task (`--task-uid`). Default last 20 runs, no body — `--with-body` for full responses, `--run-uid` for one specific run, `--status failed` to filter. |
| `bootstrap-legacy-project` | Legacy projects only. Drops `references/periodic-updates.md` and `server/_core/heartbeat.ts` into the project so the SDK in §5a becomes available. No-op when those files already exist. |

60
scripts/seed-admin.mjs Normal file
View File

@@ -0,0 +1,60 @@
/**
* Seed — Crée le compte administrateur par défaut Itinova
* Login : adminItinova
* Mot de passe : Itinova69!
* Profil : admin
*/
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import bcrypt from "bcryptjs";
import dotenv from "dotenv";
dotenv.config();
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
console.error("DATABASE_URL not set");
process.exit(1);
}
const connection = await mysql.createConnection(DATABASE_URL);
const db = drizzle(connection);
const ADMIN_LOGIN = "adminItinova";
const ADMIN_PASSWORD = "Itinova69!";
try {
// Vérifier si l'admin existe déjà
const [existing] = await connection.execute(
"SELECT id FROM users WHERE login = ?",
[ADMIN_LOGIN]
);
if (existing.length > 0) {
console.log(`✓ Le compte administrateur "${ADMIN_LOGIN}" existe déjà.`);
} else {
const passwordHash = await bcrypt.hash(ADMIN_PASSWORD, 10);
await connection.execute(
`INSERT INTO users (login, email, passwordHash, firstName, lastName, role, isActive, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
ADMIN_LOGIN,
"adminItinova@santinova-soft.org",
passwordHash,
"Admin",
"Itinova",
"admin",
1,
]
);
console.log(`✓ Compte administrateur "${ADMIN_LOGIN}" créé avec succès.`);
console.log(` Login : ${ADMIN_LOGIN}`);
console.log(` Password : ${ADMIN_PASSWORD}`);
console.log(` Profil : admin`);
}
} catch (error) {
console.error("Erreur lors du seed :", error);
process.exit(1);
} finally {
await connection.end();
}

28
server/_core/context.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { User } from "../../drizzle/schema";
import { sdk } from "./sdk";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
res: CreateExpressContextOptions["res"];
user: User | null;
};
export async function createContext(
opts: CreateExpressContextOptions
): Promise<TrpcContext> {
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
} catch (error) {
// Authentication is optional for public procedures.
user = null;
}
return {
req: opts.req,
res: opts.res,
user,
};
}

48
server/_core/cookies.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
const forwardedProto = req.headers["x-forwarded-proto"];
if (!forwardedProto) return false;
const protoList = Array.isArray(forwardedProto)
? forwardedProto
: forwardedProto.split(",");
return protoList.some(proto => proto.trim().toLowerCase() === "https");
}
export function getSessionCookieOptions(
req: Request
): Pick<CookieOptions, "domain" | "httpOnly" | "path" | "sameSite" | "secure"> {
// const hostname = req.hostname;
// const shouldSetDomain =
// hostname &&
// !LOCAL_HOSTS.has(hostname) &&
// !isIpAddress(hostname) &&
// hostname !== "127.0.0.1" &&
// hostname !== "::1";
// const domain =
// shouldSetDomain && !hostname.startsWith(".")
// ? `.${hostname}`
// : shouldSetDomain
// ? hostname
// : undefined;
return {
httpOnly: true,
path: "/",
sameSite: "none",
secure: isSecureRequest(req),
};
}

64
server/_core/dataApi.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Quick example (matches curl usage):
* await callDataApi("Youtube/search", {
* query: { gl: "US", hl: "en", q: "manus" },
* })
*/
import { ENV } from "./env";
export type DataApiCallOptions = {
query?: Record<string, unknown>;
body?: Record<string, unknown>;
pathParams?: Record<string, unknown>;
formData?: Record<string, unknown>;
};
export async function callDataApi(
apiId: string,
options: DataApiCallOptions = {}
): Promise<unknown> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/") ? ENV.forgeApiUrl : `${ENV.forgeApiUrl}/`;
const fullUrl = new URL("webdevtoken.v1.WebDevService/CallApi", baseUrl).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
apiId,
query: options.query,
body: options.body,
path_params: options.pathParams,
multipart_form_data: options.formData,
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Data API request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const payload = await response.json().catch(() => ({}));
if (payload && typeof payload === "object" && "jsonData" in payload) {
try {
return JSON.parse((payload as Record<string, string>).jsonData ?? "{}");
} catch {
return (payload as Record<string, unknown>).jsonData;
}
}
return payload;
}

10
server/_core/env.ts Normal file
View File

@@ -0,0 +1,10 @@
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
};

213
server/_core/heartbeat.ts Normal file
View File

@@ -0,0 +1,213 @@
import { TRPCError } from "@trpc/server";
import { ENV } from "./env";
export type HeartbeatJob = {
name: string;
/**
* 6-field cron with seconds (`sec min hour dom mon dow`), UTC, min interval 60s.
* Use `0` for the seconds field — e.g. `"0 0 9 * * *"` is daily 09:00 UTC.
* See periodic-updates.md.
*/
cron: string;
/** Callback path. MUST start with `/api/scheduled/`. */
path: string;
method?: "POST" | "PUT";
payload?: unknown;
description?: string;
};
/**
* Update patch. All fields optional; unset = leave unchanged.
* `enable`: true = resume, false = pause; omit = unchanged.
* `name` is the (project, owner)-scope key and cannot be changed.
*/
export type HeartbeatJobUpdate = Partial<Omit<HeartbeatJob, "name">> & {
enable?: boolean;
};
export type HeartbeatJobInfo = {
taskUid: string;
name: string;
userId: string;
description: string;
cronExpression: string;
callbackPath: string;
callbackMethod: string;
callbackPayload: string;
isEnable: boolean;
createdAt?: string | null;
lastExecutedAt?: string | null;
nextExecutionAt?: string | null;
};
const SERVICE = "webdevtoken.v1.WebDevService";
const buildEndpoint = (rpc: string): string => {
if (!ENV.forgeApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Heartbeat service URL is not configured (BUILT_IN_FORGE_API_URL).",
});
}
if (!ENV.forgeApiKey) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Heartbeat service API key is not configured (BUILT_IN_FORGE_API_KEY).",
});
}
const baseUrl = ENV.forgeApiUrl;
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return new URL(`${SERVICE}/${rpc}`, normalizedBase).toString();
};
const callForge = async <T>(
rpc: string,
body: Record<string, unknown>,
userSession: string
): Promise<T> => {
const endpoint = buildEndpoint(rpc);
const headers: Record<string, string> = {
accept: "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
"content-type": "application/json",
"connect-protocol-version": "1",
};
// userSession is the decoded `app_session_id` cookie value (NOT the raw
// Cookie header). Empty string falls back to the project owner identity.
if (userSession) {
headers["x-manus-user-session"] = userSession;
}
let response: Response;
try {
response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body),
});
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Heartbeat ${rpc} network error: ${String(error)}`,
});
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw mapForgeError(response, detail, rpc);
}
return (await response.json()) as T;
};
const mapForgeError = (
response: Response,
detail: string,
rpc: string
): TRPCError => {
const status = response.status;
let code: TRPCError["code"] = "INTERNAL_SERVER_ERROR";
if (status === 401) code = "UNAUTHORIZED";
else if (status === 403) code = "FORBIDDEN";
else if (status === 404) code = "NOT_FOUND";
else if (status === 400 || status === 422) code = "BAD_REQUEST";
else if (status === 409) code = "CONFLICT";
else if (status === 429) code = "TOO_MANY_REQUESTS";
return new TRPCError({
code,
message: `Heartbeat ${rpc} failed (${status})${detail ? `: ${detail}` : ""}`,
});
};
const stringifyPayload = (payload: unknown): string => {
if (payload === undefined || payload === null) return "{}";
if (typeof payload === "string") return payload;
return JSON.stringify(payload);
};
const validateCallbackPath = (path: string): void => {
if (!path || !path.startsWith("/api/scheduled/")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "callback path must start with /api/scheduled/",
});
}
};
/**
* Create a new HTTP cron job. Returns the assigned `taskUid` to persist on
* your business row so callbacks can dereference it.
*/
export async function createHeartbeatJob(
job: HeartbeatJob,
userSession: string
): Promise<{ taskUid: string; nextExecutionAt?: string | null }> {
validateCallbackPath(job.path);
return callForge<{ taskUid: string; nextExecutionAt?: string | null }>(
"CreateHeartbeatJob",
{
name: job.name,
cronExpression: job.cron,
callbackPath: job.path,
callbackMethod: job.method ?? "POST",
callbackPayload: stringifyPayload(job.payload),
description: job.description ?? "",
},
userSession
);
}
/**
* Update an existing cron located by `taskUid`. Only fields you pass in
* `patch` are mutated. `enable` flips resume/pause; omit to leave alone.
*/
export async function updateHeartbeatJob(
taskUid: string,
patch: HeartbeatJobUpdate,
userSession: string
): Promise<{ nextExecutionAt?: string | null }> {
if (patch.path !== undefined) validateCallbackPath(patch.path);
const body: Record<string, unknown> = { taskUid };
if (patch.cron !== undefined) body.cronExpression = patch.cron;
if (patch.path !== undefined) body.callbackPath = patch.path;
if (patch.method !== undefined) body.callbackMethod = patch.method;
if (patch.payload !== undefined) {
body.callbackPayload = stringifyPayload(patch.payload);
}
if (patch.description !== undefined) body.description = patch.description;
if (patch.enable !== undefined) body.enable = patch.enable;
return callForge<{ nextExecutionAt?: string | null }>(
"UpdateHeartbeatJob",
body,
userSession
);
}
/** Delete a cron located by `taskUid`. Idempotent on caller side. */
export async function deleteHeartbeatJob(
taskUid: string,
userSession: string
): Promise<void> {
await callForge("DeleteHeartbeatJob", { taskUid }, userSession);
}
/**
* List cron jobs owned by the resolved actor (end-user when `userSession`
* is set, project owner otherwise) within the current project.
*
* `actorUserId` in the response echoes whose cron list you got back. End-users
* cannot list other users' crons via this SDK; cross-user inspection is
* owner-only via the sandbox CLI (`manus-heartbeat list --user-id <uid>`).
*/
export async function listHeartbeatJobs(
userSession: string,
pagination?: { page?: number; pageSize?: number }
): Promise<{ total: number; actorUserId: string; jobs: HeartbeatJobInfo[] }> {
const body: Record<string, unknown> = {};
if (pagination?.page !== undefined) body.page = pagination.page;
if (pagination?.pageSize !== undefined) body.pageSize = pagination.pageSize;
return callForge<{
total: number;
actorUserId: string;
jobs: HeartbeatJobInfo[];
}>("ListHeartbeatJobs", body, userSession);
}

View File

@@ -0,0 +1,92 @@
/**
* Image generation helper using internal ImageService
*
* Example usage:
* const { url: imageUrl } = await generateImage({
* prompt: "A serene landscape with mountains"
* });
*
* For editing:
* const { url: imageUrl } = await generateImage({
* prompt: "Add a rainbow to this landscape",
* originalImages: [{
* url: "https://example.com/original.jpg",
* mimeType: "image/jpeg"
* }]
* });
*/
import { storagePut } from "server/storage";
import { ENV } from "./env";
export type GenerateImageOptions = {
prompt: string;
originalImages?: Array<{
url?: string;
b64Json?: string;
mimeType?: string;
}>;
};
export type GenerateImageResponse = {
url?: string;
};
export async function generateImage(
options: GenerateImageOptions
): Promise<GenerateImageResponse> {
if (!ENV.forgeApiUrl) {
throw new Error("BUILT_IN_FORGE_API_URL is not configured");
}
if (!ENV.forgeApiKey) {
throw new Error("BUILT_IN_FORGE_API_KEY is not configured");
}
// Build the full URL by appending the service path to the base URL
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"images.v1.ImageService/GenerateImage",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"connect-protocol-version": "1",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify({
prompt: options.prompt,
original_images: options.originalImages || [],
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(
`Image generation request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`
);
}
const result = (await response.json()) as {
image: {
b64Json: string;
mimeType: string;
};
};
const base64Data = result.image.b64Json;
const buffer = Buffer.from(base64Data, "base64");
// Save to S3
const { url } = await storagePut(
`generated/${Date.now()}.png`,
buffer,
result.image.mimeType
);
return {
url,
};
}

66
server/_core/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { registerStorageProxy } from "./storageProxy";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
}
async function findAvailablePort(startPort: number = 3000): Promise<number> {
for (let port = startPort; port < startPort + 20; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found starting from ${startPort}`);
}
async function startServer() {
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
registerStorageProxy(app);
registerOAuthRoutes(app);
// tRPC API
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
})
);
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
}
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
startServer().catch(console.error);

383
server/_core/llm.ts Normal file
View File

@@ -0,0 +1,383 @@
import { ENV } from "./env";
export type Role = "system" | "user" | "assistant" | "tool" | "function";
export type TextContent = {
type: "text";
text: string;
};
export type ImageContent = {
type: "image_url";
image_url: {
url: string;
detail?: "auto" | "low" | "high";
};
};
export type FileContent = {
type: "file_url";
file_url: {
url: string;
mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
};
};
export type MessageContent = string | TextContent | ImageContent | FileContent;
export type Message = {
role: Role;
content: MessageContent | MessageContent[];
name?: string;
tool_call_id?: string;
};
export type Tool = {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
};
export type ToolChoicePrimitive = "none" | "auto" | "required";
export type ToolChoiceByName = { name: string };
export type ToolChoiceExplicit = {
type: "function";
function: {
name: string;
};
};
export type ToolChoice =
| ToolChoicePrimitive
| ToolChoiceByName
| ToolChoiceExplicit;
export type InvokeParams = {
messages: Message[];
tools?: Tool[];
toolChoice?: ToolChoice;
tool_choice?: ToolChoice;
maxTokens?: number;
max_tokens?: number;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
model?: string;
thinking?: Record<string, unknown>;
reasoning?: Record<string, unknown>;
};
export type ToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type InvokeResult = {
id: string;
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: Role;
content: string | Array<TextContent | ImageContent | FileContent>;
tool_calls?: ToolCall[];
};
finish_reason: string | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export type JsonSchema = {
name: string;
schema: Record<string, unknown>;
strict?: boolean;
};
export type OutputSchema = JsonSchema;
export type ResponseFormat =
| { type: "text" }
| { type: "json_object" }
| { type: "json_schema"; json_schema: JsonSchema };
const ensureArray = (
value: MessageContent | MessageContent[]
): MessageContent[] => (Array.isArray(value) ? value : [value]);
const normalizeContentPart = (
part: MessageContent
): TextContent | ImageContent | FileContent => {
if (typeof part === "string") {
return { type: "text", text: part };
}
if (part.type === "text") {
return part;
}
if (part.type === "image_url") {
return part;
}
if (part.type === "file_url") {
return part;
}
throw new Error("Unsupported message content part");
};
const normalizeMessage = (message: Message) => {
const { role, name, tool_call_id } = message;
if (role === "tool" || role === "function") {
const content = ensureArray(message.content)
.map(part => (typeof part === "string" ? part : JSON.stringify(part)))
.join("\n");
return {
role,
name,
tool_call_id,
content,
};
}
const contentParts = ensureArray(message.content).map(normalizeContentPart);
// If there's only text content, collapse to a single string for compatibility
if (contentParts.length === 1 && contentParts[0].type === "text") {
return {
role,
name,
content: contentParts[0].text,
};
}
return {
role,
name,
content: contentParts,
};
};
const normalizeToolChoice = (
toolChoice: ToolChoice | undefined,
tools: Tool[] | undefined
): "none" | "auto" | ToolChoiceExplicit | undefined => {
if (!toolChoice) return undefined;
if (toolChoice === "none" || toolChoice === "auto") {
return toolChoice;
}
if (toolChoice === "required") {
if (!tools || tools.length === 0) {
throw new Error(
"tool_choice 'required' was provided but no tools were configured"
);
}
if (tools.length > 1) {
throw new Error(
"tool_choice 'required' needs a single tool or specify the tool name explicitly"
);
}
return {
type: "function",
function: { name: tools[0].function.name },
};
}
if ("name" in toolChoice) {
return {
type: "function",
function: { name: toolChoice.name },
};
}
return toolChoice;
};
const resolveApiUrl = () =>
ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/chat/completions`
: "https://forge.manus.im/v1/chat/completions";
const assertApiKey = () => {
if (!ENV.forgeApiKey) {
throw new Error("OPENAI_API_KEY is not configured");
}
};
const normalizeResponseFormat = ({
responseFormat,
response_format,
outputSchema,
output_schema,
}: {
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
outputSchema?: OutputSchema;
output_schema?: OutputSchema;
}):
| { type: "json_schema"; json_schema: JsonSchema }
| { type: "text" }
| { type: "json_object" }
| undefined => {
const explicitFormat = responseFormat || response_format;
if (explicitFormat) {
if (
explicitFormat.type === "json_schema" &&
!explicitFormat.json_schema?.schema
) {
throw new Error(
"responseFormat json_schema requires a defined schema object"
);
}
return explicitFormat;
}
const schema = outputSchema || output_schema;
if (!schema) return undefined;
if (!schema.name || !schema.schema) {
throw new Error("outputSchema requires both name and schema");
}
return {
type: "json_schema",
json_schema: {
name: schema.name,
schema: schema.schema,
...(typeof schema.strict === "boolean" ? { strict: schema.strict } : {}),
},
};
};
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
assertApiKey();
const {
messages,
tools,
toolChoice,
tool_choice,
outputSchema,
output_schema,
responseFormat,
response_format,
model,
thinking,
reasoning,
maxTokens,
max_tokens,
} = params;
const payload: Record<string, unknown> = {
messages: messages.map(normalizeMessage),
};
if (model) {
payload.model = model;
}
if (tools && tools.length > 0) {
payload.tools = tools;
}
const normalizedToolChoice = normalizeToolChoice(
toolChoice || tool_choice,
tools
);
if (normalizedToolChoice) {
payload.tool_choice = normalizedToolChoice;
}
const resolvedMaxTokens = max_tokens ?? maxTokens;
if (typeof resolvedMaxTokens === "number") {
payload.max_tokens = resolvedMaxTokens;
}
if (thinking) {
payload.thinking = thinking;
}
if (reasoning) {
payload.reasoning = reasoning;
}
const normalizedResponseFormat = normalizeResponseFormat({
responseFormat,
response_format,
outputSchema,
output_schema,
});
if (normalizedResponseFormat) {
payload.response_format = normalizedResponseFormat;
}
const response = await fetch(resolveApiUrl(), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`LLM invoke failed: ${response.status} ${response.statusText} ${errorText}`
);
}
return (await response.json()) as InvokeResult;
}
export type ModelInfo = {
id: string;
object: string;
created: number;
owned_by: string;
};
export type ModelsResponse = {
object: string;
data: ModelInfo[];
};
export async function listLLMModels(): Promise<ModelsResponse> {
assertApiKey();
const url = ENV.forgeApiUrl && ENV.forgeApiUrl.trim().length > 0
? `${ENV.forgeApiUrl.replace(/\/$/, "")}/v1/models`
: "https://forge.manus.im/v1/models";
const response = await fetch(url, {
headers: { authorization: `Bearer ${ENV.forgeApiKey}` },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`List LLM models failed: ${response.status} ${response.statusText} ${errorText}`
);
}
return (await response.json()) as ModelsResponse;
}

319
server/_core/map.ts Normal file
View File

@@ -0,0 +1,319 @@
/**
* Google Maps API Integration for Manus WebDev Templates
*
* Main function: makeRequest<T>(endpoint, params) - Makes authenticated requests to Google Maps APIs
* All credentials are automatically injected. Array parameters use | as separator.
*
* See API examples below the type definitions for usage patterns.
*/
import { ENV } from "./env";
// ============================================================================
// Configuration
// ============================================================================
type MapsConfig = {
baseUrl: string;
apiKey: string;
};
function getMapsConfig(): MapsConfig {
const baseUrl = ENV.forgeApiUrl;
const apiKey = ENV.forgeApiKey;
if (!baseUrl || !apiKey) {
throw new Error(
"Google Maps proxy credentials missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY"
);
}
return {
baseUrl: baseUrl.replace(/\/+$/, ""),
apiKey,
};
}
// ============================================================================
// Core Request Handler
// ============================================================================
interface RequestOptions {
method?: "GET" | "POST";
body?: Record<string, unknown>;
}
/**
* Make authenticated requests to Google Maps APIs
*
* @param endpoint - The API endpoint (e.g., "/maps/api/geocode/json")
* @param params - Query parameters for the request
* @param options - Additional request options
* @returns The API response
*/
export async function makeRequest<T = unknown>(
endpoint: string,
params: Record<string, unknown> = {},
options: RequestOptions = {}
): Promise<T> {
const { baseUrl, apiKey } = getMapsConfig();
// Construct full URL: baseUrl + /v1/maps/proxy + endpoint
const url = new URL(`${baseUrl}/v1/maps/proxy${endpoint}`);
// Add API key as query parameter (standard Google Maps API authentication)
url.searchParams.append("key", apiKey);
// Add other query parameters
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, String(value));
}
});
const response = await fetch(url.toString(), {
method: options.method || "GET",
headers: {
"Content-Type": "application/json",
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Google Maps API request failed (${response.status} ${response.statusText}): ${errorText}`
);
}
return (await response.json()) as T;
}
// ============================================================================
// Type Definitions
// ============================================================================
export type TravelMode = "driving" | "walking" | "bicycling" | "transit";
export type MapType = "roadmap" | "satellite" | "terrain" | "hybrid";
export type SpeedUnit = "KPH" | "MPH";
export type LatLng = {
lat: number;
lng: number;
};
export type DirectionsResult = {
routes: Array<{
legs: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
start_address: string;
end_address: string;
start_location: LatLng;
end_location: LatLng;
steps: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
html_instructions: string;
travel_mode: string;
start_location: LatLng;
end_location: LatLng;
}>;
}>;
overview_polyline: { points: string };
summary: string;
warnings: string[];
waypoint_order: number[];
}>;
status: string;
};
export type DistanceMatrixResult = {
rows: Array<{
elements: Array<{
distance: { text: string; value: number };
duration: { text: string; value: number };
status: string;
}>;
}>;
origin_addresses: string[];
destination_addresses: string[];
status: string;
};
export type GeocodingResult = {
results: Array<{
address_components: Array<{
long_name: string;
short_name: string;
types: string[];
}>;
formatted_address: string;
geometry: {
location: LatLng;
location_type: string;
viewport: {
northeast: LatLng;
southwest: LatLng;
};
};
place_id: string;
types: string[];
}>;
status: string;
};
export type PlacesSearchResult = {
results: Array<{
place_id: string;
name: string;
formatted_address: string;
geometry: {
location: LatLng;
};
rating?: number;
user_ratings_total?: number;
business_status?: string;
types: string[];
}>;
status: string;
};
export type PlaceDetailsResult = {
result: {
place_id: string;
name: string;
formatted_address: string;
formatted_phone_number?: string;
international_phone_number?: string;
website?: string;
rating?: number;
user_ratings_total?: number;
reviews?: Array<{
author_name: string;
rating: number;
text: string;
time: number;
}>;
opening_hours?: {
open_now: boolean;
weekday_text: string[];
};
geometry: {
location: LatLng;
};
};
status: string;
};
export type ElevationResult = {
results: Array<{
elevation: number;
location: LatLng;
resolution: number;
}>;
status: string;
};
export type TimeZoneResult = {
dstOffset: number;
rawOffset: number;
status: string;
timeZoneId: string;
timeZoneName: string;
};
export type RoadsResult = {
snappedPoints: Array<{
location: LatLng;
originalIndex?: number;
placeId: string;
}>;
};
// ============================================================================
// Google Maps API Reference
// ============================================================================
/**
* GEOCODING - Convert between addresses and coordinates
* Endpoint: /maps/api/geocode/json
* Input: { address: string } OR { latlng: string } // latlng: "37.42,-122.08"
* Output: GeocodingResult // results[0].geometry.location, results[0].formatted_address
*/
/**
* DIRECTIONS - Get navigation routes between locations
* Endpoint: /maps/api/directions/json
* Input: { origin: string, destination: string, mode?: TravelMode, waypoints?: string, alternatives?: boolean }
* Output: DirectionsResult // routes[0].legs[0].distance, duration, steps
*/
/**
* DISTANCE MATRIX - Calculate travel times/distances for multiple origin-destination pairs
* Endpoint: /maps/api/distancematrix/json
* Input: { origins: string, destinations: string, mode?: TravelMode, units?: "metric"|"imperial" } // origins: "NYC|Boston"
* Output: DistanceMatrixResult // rows[0].elements[1] = first origin to second destination
*/
/**
* PLACE SEARCH - Find businesses/POIs by text query
* Endpoint: /maps/api/place/textsearch/json
* Input: { query: string, location?: string, radius?: number, type?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult // results[].name, rating, geometry.location, place_id
*/
/**
* NEARBY SEARCH - Find places near a specific location
* Endpoint: /maps/api/place/nearbysearch/json
* Input: { location: string, radius: number, type?: string, keyword?: string } // location: "40.7,-74.0"
* Output: PlacesSearchResult
*/
/**
* PLACE DETAILS - Get comprehensive information about a specific place
* Endpoint: /maps/api/place/details/json
* Input: { place_id: string, fields?: string } // fields: "name,rating,opening_hours,website"
* Output: PlaceDetailsResult // result.name, rating, opening_hours, etc.
*/
/**
* ELEVATION - Get altitude data for geographic points
* Endpoint: /maps/api/elevation/json
* Input: { locations?: string, path?: string, samples?: number } // locations: "39.73,-104.98|36.45,-116.86"
* Output: ElevationResult // results[].elevation (meters)
*/
/**
* TIME ZONE - Get timezone information for a location
* Endpoint: /maps/api/timezone/json
* Input: { location: string, timestamp: number } // timestamp: Math.floor(Date.now()/1000)
* Output: TimeZoneResult // timeZoneId, timeZoneName
*/
/**
* ROADS - Snap GPS traces to roads, find nearest roads, get speed limits
* - /v1/snapToRoads: Input: { path: string, interpolate?: boolean } // path: "lat,lng|lat,lng"
* - /v1/nearestRoads: Input: { points: string } // points: "lat,lng|lat,lng"
* - /v1/speedLimits: Input: { path: string, units?: SpeedUnit }
* Output: RoadsResult
*/
/**
* PLACE AUTOCOMPLETE - Real-time place suggestions as user types
* Endpoint: /maps/api/place/autocomplete/json
* Input: { input: string, location?: string, radius?: number }
* Output: { predictions: Array<{ description: string, place_id: string }> }
*/
/**
* STATIC MAPS - Generate map images as URLs (for emails, reports, <img> tags)
* Endpoint: /maps/api/staticmap
* Input: URL params - center: string, zoom: number, size: string, markers?: string, maptype?: MapType
* Output: Image URL (not JSON) - use directly in <img src={url} />
* Note: Construct URL manually with getMapsConfig() for auth
*/

View File

@@ -0,0 +1,114 @@
import { TRPCError } from "@trpc/server";
import { ENV } from "./env";
export type NotificationPayload = {
title: string;
content: string;
};
const TITLE_MAX_LENGTH = 1200;
const CONTENT_MAX_LENGTH = 20000;
const trimValue = (value: string): string => value.trim();
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
const buildEndpointUrl = (baseUrl: string): string => {
const normalizedBase = baseUrl.endsWith("/")
? baseUrl
: `${baseUrl}/`;
return new URL(
"webdevtoken.v1.WebDevService/SendNotification",
normalizedBase
).toString();
};
const validatePayload = (input: NotificationPayload): NotificationPayload => {
if (!isNonEmptyString(input.title)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification title is required.",
});
}
if (!isNonEmptyString(input.content)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Notification content is required.",
});
}
const title = trimValue(input.title);
const content = trimValue(input.content);
if (title.length > TITLE_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification title must be at most ${TITLE_MAX_LENGTH} characters.`,
});
}
if (content.length > CONTENT_MAX_LENGTH) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Notification content must be at most ${CONTENT_MAX_LENGTH} characters.`,
});
}
return { title, content };
};
/**
* Dispatches a project-owner notification through the Manus Notification Service.
* Returns `true` if the request was accepted, `false` when the upstream service
* cannot be reached (callers can fall back to email/slack). Validation errors
* bubble up as TRPC errors so callers can fix the payload.
*/
export async function notifyOwner(
payload: NotificationPayload
): Promise<boolean> {
const { title, content } = validatePayload(payload);
if (!ENV.forgeApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Notification service URL is not configured.",
});
}
if (!ENV.forgeApiKey) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Notification service API key is not configured.",
});
}
const endpoint = buildEndpointUrl(ENV.forgeApiUrl);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${ENV.forgeApiKey}`,
"content-type": "application/json",
"connect-protocol-version": "1",
},
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.warn(
`[Notification] Failed to notify owner (${response.status} ${response.statusText})${
detail ? `: ${detail}` : ""
}`
);
return false;
}
return true;
} catch (error) {
console.warn("[Notification] Error calling notification service:", error);
return false;
}
}

19
server/_core/oauth.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Routes d'authentification locale Itinova.
* Le flow Manus OAuth est désactivé — on utilise email/password + JWT local.
*/
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import { getSessionCookieOptions } from "./cookies";
/**
* Enregistre les routes d'auth locale.
* Le vrai endpoint de login est géré via tRPC (auth.login).
* Cette fonction est conservée pour compatibilité avec le framework.
*/
export function registerOAuthRoutes(app: Express) {
// Route de callback Manus OAuth désactivée — auth locale uniquement
app.get("/api/oauth/callback", (_req: Request, res: Response) => {
res.redirect(302, "/login");
});
}

98
server/_core/sdk.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* SDK Server — Auth locale Itinova
* Remplace le flow Manus OAuth par une authentification locale email/password + JWT.
* Le cookie de session contient un JWT signé avec JWT_SECRET.
*/
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
export type SessionPayload = {
userId: number;
login: string;
role: string;
};
/** Result of `sdk.authenticateRequest`. */
export type AuthenticatedUser = User & {
taskUid?: string;
isCron?: boolean;
};
class SDKServer {
private getSessionSecret() {
const secret = ENV.cookieSecret;
return new TextEncoder().encode(secret);
}
async createSessionToken(
userId: number,
login: string,
role: string,
options: { expiresInMs?: number } = {}
): Promise<string> {
const issuedAt = Date.now();
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
const secretKey = this.getSessionSecret();
return new SignJWT({ userId, login, role })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
.sign(secretKey);
}
async verifySession(
cookieValue: string | undefined | null
): Promise<SessionPayload | null> {
if (!cookieValue) {
return null;
}
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
const { userId, login, role } = payload as Record<string, unknown>;
if (!userId || !isNonEmptyString(login) || !isNonEmptyString(role)) {
return null;
}
return { userId: userId as number, login, role };
} catch {
return null;
}
}
private parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) return new Map<string, string>();
const parsed = parseCookieHeader(cookieHeader);
return new Map(Object.entries(parsed));
}
async authenticateRequest(req: Request): Promise<AuthenticatedUser> {
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = cookies.get(COOKIE_NAME);
const session = await this.verifySession(sessionCookie);
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const user = await db.getUserById(session.userId);
if (!user || !user.isActive) {
throw ForbiddenError("User not found or inactive");
}
return user as AuthenticatedUser;
}
}
export const sdk = new SDKServer();

View File

@@ -0,0 +1,48 @@
import type { Express } from "express";
import { ENV } from "./env";
export function registerStorageProxy(app: Express) {
app.get("/manus-storage/*", async (req, res) => {
const key = (req.params as Record<string, string>)[0];
if (!key) {
res.status(400).send("Missing storage key");
return;
}
if (!ENV.forgeApiUrl || !ENV.forgeApiKey) {
res.status(500).send("Storage proxy not configured");
return;
}
try {
const forgeUrl = new URL(
"v1/storage/presign/get",
ENV.forgeApiUrl.replace(/\/+$/, "") + "/",
);
forgeUrl.searchParams.set("path", key);
const forgeResp = await fetch(forgeUrl, {
headers: { Authorization: `Bearer ${ENV.forgeApiKey}` },
});
if (!forgeResp.ok) {
const body = await forgeResp.text().catch(() => "");
console.error(`[StorageProxy] forge error: ${forgeResp.status} ${body}`);
res.status(502).send("Storage backend error");
return;
}
const { url } = (await forgeResp.json()) as { url: string };
if (!url) {
res.status(502).send("Empty signed URL from backend");
return;
}
res.set("Cache-Control", "no-store");
res.redirect(307, url);
} catch (err) {
console.error("[StorageProxy] failed:", err);
res.status(502).send("Storage proxy error");
}
});
}

View File

@@ -0,0 +1,29 @@
import { z } from "zod";
import { notifyOwner } from "./notification";
import { adminProcedure, publicProcedure, router } from "./trpc";
export const systemRouter = router({
health: publicProcedure
.input(
z.object({
timestamp: z.number().min(0, "timestamp cannot be negative"),
})
)
.query(() => ({
ok: true,
})),
notifyOwner: adminProcedure
.input(
z.object({
title: z.string().min(1, "title is required"),
content: z.string().min(1, "content is required"),
})
)
.mutation(async ({ input }) => {
const delivered = await notifyOwner(input);
return {
success: delivered,
} as const;
}),
});

45
server/_core/trpc.ts Normal file
View File

@@ -0,0 +1,45 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
const t = initTRPC.context<TrpcContext>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
}),
);

6
server/_core/types/cookie.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module "cookie" {
export function parse(
str: string,
options?: Record<string, unknown>
): Record<string, string>;
}

View File

@@ -0,0 +1,71 @@
// WebDev Auth TypeScript types
// Auto-generated from protobuf definitions
// Generated on: 2025-09-24T05:57:57.338Z
export interface AuthorizeRequest {
redirectUri: string;
projectId: string;
state: string;
responseType: string;
scope: string;
}
export interface AuthorizeResponse {
redirectUrl: string;
}
export interface ExchangeTokenRequest {
grantType: string;
code: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}
export interface ExchangeTokenResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
refreshToken?: string;
scope: string;
idToken: string;
}
export interface GetUserInfoRequest {
accessToken: string;
}
export interface GetUserInfoResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}
export interface CanAccessRequest {
openId: string;
projectId: string;
}
export interface CanAccessResponse {
canAccess: boolean;
}
export interface GetUserInfoWithJwtRequest {
jwtToken: string;
projectId: string;
}
export interface GetUserInfoWithJwtResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
/** Cron-only; references `schedule_task.uid`. */
taskUid?: string | null;
}

67
server/_core/vite.ts Normal file
View File

@@ -0,0 +1,67 @@
import express, { type Express } from "express";
import fs from "fs";
import { type Server } from "http";
import { nanoid } from "nanoid";
import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) {
const serverOptions = {
middlewareMode: true,
hmr: { server },
allowedHosts: true as const,
};
const vite = await createViteServer({
...viteConfig,
configFile: false,
server: serverOptions,
appType: "custom",
});
app.use(vite.middlewares);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
try {
const clientTemplate = path.resolve(
import.meta.dirname,
"../..",
"client",
"index.html"
);
// always reload the index.html file from disk incase it changes
let template = await fs.promises.readFile(clientTemplate, "utf-8");
template = template.replace(
`src="/src/main.tsx"`,
`src="/src/main.tsx?v=${nanoid()}"`
);
const page = await vite.transformIndexHtml(url, template);
res.status(200).set({ "Content-Type": "text/html" }).end(page);
} catch (e) {
vite.ssrFixStacktrace(e as Error);
next(e);
}
});
}
export function serveStatic(app: Express) {
const distPath =
process.env.NODE_ENV === "development"
? path.resolve(import.meta.dirname, "../..", "dist", "public")
: path.resolve(import.meta.dirname, "public");
if (!fs.existsSync(distPath)) {
console.error(
`Could not find the build directory: ${distPath}, make sure to build the client first`
);
}
app.use(express.static(distPath));
// fall through to index.html if the file doesn't exist
app.use("*", (_req, res) => {
res.sendFile(path.resolve(distPath, "index.html"));
});
}

View File

@@ -0,0 +1,284 @@
/**
* Voice transcription helper using internal Speech-to-Text service
*
* Frontend implementation guide:
* 1. Capture audio using MediaRecorder API
* 2. Upload audio to storage (e.g., S3) to get URL
* 3. Call transcription with the URL
*
* Example usage:
* ```tsx
* // Frontend component
* const transcribeMutation = trpc.voice.transcribe.useMutation({
* onSuccess: (data) => {
* console.log(data.text); // Full transcription
* console.log(data.language); // Detected language
* console.log(data.segments); // Timestamped segments
* }
* });
*
* // After uploading audio to storage
* transcribeMutation.mutate({
* audioUrl: uploadedAudioUrl,
* language: 'en', // optional
* prompt: 'Transcribe the meeting' // optional
* });
* ```
*/
import { ENV } from "./env";
export type TranscribeOptions = {
audioUrl: string; // URL to the audio file (e.g., S3 URL)
language?: string; // Optional: specify language code (e.g., "en", "es", "zh")
prompt?: string; // Optional: custom prompt for the transcription
};
// Native Whisper API segment format
export type WhisperSegment = {
id: number;
seek: number;
start: number;
end: number;
text: string;
tokens: number[];
temperature: number;
avg_logprob: number;
compression_ratio: number;
no_speech_prob: number;
};
// Native Whisper API response format
export type WhisperResponse = {
task: "transcribe";
language: string;
duration: number;
text: string;
segments: WhisperSegment[];
};
export type TranscriptionResponse = WhisperResponse; // Return native Whisper API response directly
export type TranscriptionError = {
error: string;
code: "FILE_TOO_LARGE" | "INVALID_FORMAT" | "TRANSCRIPTION_FAILED" | "UPLOAD_FAILED" | "SERVICE_ERROR";
details?: string;
};
/**
* Transcribe audio to text using the internal Speech-to-Text service
*
* @param options - Audio data and metadata
* @returns Transcription result or error
*/
export async function transcribeAudio(
options: TranscribeOptions
): Promise<TranscriptionResponse | TranscriptionError> {
try {
// Step 1: Validate environment configuration
if (!ENV.forgeApiUrl) {
return {
error: "Voice transcription service is not configured",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_URL is not set"
};
}
if (!ENV.forgeApiKey) {
return {
error: "Voice transcription service authentication is missing",
code: "SERVICE_ERROR",
details: "BUILT_IN_FORGE_API_KEY is not set"
};
}
// Step 2: Download audio from URL
let audioBuffer: Buffer;
let mimeType: string;
try {
const response = await fetch(options.audioUrl);
if (!response.ok) {
return {
error: "Failed to download audio file",
code: "INVALID_FORMAT",
details: `HTTP ${response.status}: ${response.statusText}`
};
}
audioBuffer = Buffer.from(await response.arrayBuffer());
mimeType = response.headers.get('content-type') || 'audio/mpeg';
// Check file size (16MB limit)
const sizeMB = audioBuffer.length / (1024 * 1024);
if (sizeMB > 16) {
return {
error: "Audio file exceeds maximum size limit",
code: "FILE_TOO_LARGE",
details: `File size is ${sizeMB.toFixed(2)}MB, maximum allowed is 16MB`
};
}
} catch (error) {
return {
error: "Failed to fetch audio file",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "Unknown error"
};
}
// Step 3: Create FormData for multipart upload to Whisper API
const formData = new FormData();
// Create a Blob from the buffer and append to form
const filename = `audio.${getFileExtension(mimeType)}`;
const audioBlob = new Blob([new Uint8Array(audioBuffer)], { type: mimeType });
formData.append("file", audioBlob, filename);
formData.append("model", "whisper-1");
formData.append("response_format", "verbose_json");
// Add prompt - use custom prompt if provided, otherwise generate based on language
const prompt = options.prompt || (
options.language
? `Transcribe the user's voice to text, the user's working language is ${getLanguageName(options.language)}`
: "Transcribe the user's voice to text"
);
formData.append("prompt", prompt);
// Step 4: Call the transcription service
const baseUrl = ENV.forgeApiUrl.endsWith("/")
? ENV.forgeApiUrl
: `${ENV.forgeApiUrl}/`;
const fullUrl = new URL(
"v1/audio/transcriptions",
baseUrl
).toString();
const response = await fetch(fullUrl, {
method: "POST",
headers: {
authorization: `Bearer ${ENV.forgeApiKey}`,
"Accept-Encoding": "identity",
},
body: formData,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
return {
error: "Transcription service request failed",
code: "TRANSCRIPTION_FAILED",
details: `${response.status} ${response.statusText}${errorText ? `: ${errorText}` : ""}`
};
}
// Step 5: Parse and return the transcription result
const whisperResponse = await response.json() as WhisperResponse;
// Validate response structure
if (!whisperResponse.text || typeof whisperResponse.text !== 'string') {
return {
error: "Invalid transcription response",
code: "SERVICE_ERROR",
details: "Transcription service returned an invalid response format"
};
}
return whisperResponse; // Return native Whisper API response directly
} catch (error) {
// Handle unexpected errors
return {
error: "Voice transcription failed",
code: "SERVICE_ERROR",
details: error instanceof Error ? error.message : "An unexpected error occurred"
};
}
}
/**
* Helper function to get file extension from MIME type
*/
function getFileExtension(mimeType: string): string {
const mimeToExt: Record<string, string> = {
'audio/webm': 'webm',
'audio/mp3': 'mp3',
'audio/mpeg': 'mp3',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/ogg': 'ogg',
'audio/m4a': 'm4a',
'audio/mp4': 'm4a',
};
return mimeToExt[mimeType] || 'audio';
}
/**
* Helper function to get full language name from ISO code
*/
function getLanguageName(langCode: string): string {
const langMap: Record<string, string> = {
'en': 'English',
'es': 'Spanish',
'fr': 'French',
'de': 'German',
'it': 'Italian',
'pt': 'Portuguese',
'ru': 'Russian',
'ja': 'Japanese',
'ko': 'Korean',
'zh': 'Chinese',
'ar': 'Arabic',
'hi': 'Hindi',
'nl': 'Dutch',
'pl': 'Polish',
'tr': 'Turkish',
'sv': 'Swedish',
'da': 'Danish',
'no': 'Norwegian',
'fi': 'Finnish',
};
return langMap[langCode] || langCode;
}
/**
* Example tRPC procedure implementation:
*
* ```ts
* // In server/routers.ts
* import { transcribeAudio } from "./_core/voiceTranscription";
*
* export const voiceRouter = router({
* transcribe: protectedProcedure
* .input(z.object({
* audioUrl: z.string(),
* language: z.string().optional(),
* prompt: z.string().optional(),
* }))
* .mutation(async ({ input, ctx }) => {
* const result = await transcribeAudio(input);
*
* // Check if it's an error
* if ('error' in result) {
* throw new TRPCError({
* code: 'BAD_REQUEST',
* message: result.error,
* cause: result,
* });
* }
*
* // Optionally save transcription to database
* await db.insert(transcriptions).values({
* userId: ctx.user.id,
* text: result.text,
* duration: result.duration,
* language: result.language,
* audioUrl: input.audioUrl,
* createdAt: new Date(),
* });
*
* return result;
* }),
* });
* ```
*/

270
server/auth.local.test.ts Normal file
View File

@@ -0,0 +1,270 @@
/**
* Tests Vitest — Authentification locale Itinova Budget SI
* Couvre : login, logout, me, users.create, etablissements.list
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import type { User } from "../drizzle/schema";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
login: "admin",
email: "admin@itinova.fr",
passwordHash: "$2a$10$hashedpassword",
firstName: "Admin",
lastName: "Itinova",
role: "admin",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: null,
...overrides,
};
}
type CookieCall = { name: string; options: Record<string, unknown> };
function createPublicCtx(): { ctx: TrpcContext; setCookies: CookieCall[]; clearedCookies: CookieCall[] } {
const setCookies: CookieCall[] = [];
const clearedCookies: CookieCall[] = [];
const ctx: TrpcContext = {
user: null,
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: {
cookie: (name: string, _val: string, options: Record<string, unknown>) => setCookies.push({ name, options }),
clearCookie: (name: string, options: Record<string, unknown>) => clearedCookies.push({ name, options }),
} as unknown as TrpcContext["res"],
};
return { ctx, setCookies, clearedCookies };
}
function createAuthCtx(userOverrides: Partial<User> = {}): { ctx: TrpcContext; clearedCookies: CookieCall[] } {
const clearedCookies: CookieCall[] = [];
const ctx: TrpcContext = {
user: makeUser(userOverrides),
req: { protocol: "https", headers: {} } as TrpcContext["req"],
res: {
cookie: () => {},
clearCookie: (name: string, options: Record<string, unknown>) => clearedCookies.push({ name, options }),
} as unknown as TrpcContext["res"],
};
return { ctx, clearedCookies };
}
// ─── Mock db ─────────────────────────────────────────────────────────────────
vi.mock("./db", async (importOriginal) => {
const actual = await importOriginal<typeof import("./db")>();
return {
...actual,
getUserByLogin: vi.fn(),
updateLastSignedIn: vi.fn(),
createUser: vi.fn(),
listUsers: vi.fn(),
listEtablissements: vi.fn(),
getParametres: vi.fn(),
};
});
vi.mock("./_core/sdk", () => ({
sdk: {
createSessionToken: vi.fn().mockResolvedValue("mock-jwt-token"),
authenticateRequest: vi.fn(),
},
}));
// ─── Tests auth.logout ────────────────────────────────────────────────────────
describe("auth.logout", () => {
it("efface le cookie de session et retourne success:true", async () => {
const { ctx, clearedCookies } = createAuthCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
httpOnly: true,
path: "/",
});
});
it("fonctionne aussi sans utilisateur connecté (public procedure)", async () => {
const { ctx, clearedCookies } = createPublicCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
});
});
// ─── Tests auth.me ────────────────────────────────────────────────────────────
describe("auth.me", () => {
it("retourne null si non authentifié", async () => {
const { ctx } = createPublicCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.me();
expect(result).toBeNull();
});
it("retourne les infos utilisateur si authentifié", async () => {
const { ctx } = createAuthCtx({ login: "jdupont", email: "j.dupont@itinova.fr", role: "standard" });
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.me();
expect(result).not.toBeNull();
expect(result?.login).toBe("jdupont");
expect(result?.email).toBe("j.dupont@itinova.fr");
expect(result?.role).toBe("standard");
});
it("ne retourne pas le hash du mot de passe", async () => {
const { ctx } = createAuthCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.me();
expect(result).not.toHaveProperty("passwordHash");
});
});
// ─── Tests auth.login ────────────────────────────────────────────────────────
describe("auth.login", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("refuse un login avec identifiants invalides (utilisateur inexistant)", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.getUserByLogin as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const { ctx } = createPublicCtx();
const caller = appRouter.createCaller(ctx);
await expect(caller.auth.login({ login: "inexistant", password: "mauvais" }))
.rejects.toThrow("Identifiants invalides");
});
it("refuse un compte inactif", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.getUserByLogin as ReturnType<typeof vi.fn>).mockResolvedValue(
makeUser({ isActive: false })
);
const { ctx } = createPublicCtx();
const caller = appRouter.createCaller(ctx);
await expect(caller.auth.login({ login: "admin", password: "password" }))
.rejects.toThrow("Identifiants invalides");
});
});
// ─── Tests etablissements.list ────────────────────────────────────────────────
describe("etablissements.list", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne la liste des établissements pour un utilisateur connecté", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const mockEtabs = [
{ id: 1, code: "ETB001", nom: "EHPAD Les Pins", groupe: "Itinova", ville: "Lyon", actif: true, createdAt: new Date(), updatedAt: new Date() },
{ id: 2, code: "ETB002", nom: "Résidence Soleil", groupe: "Itinova", ville: "Grenoble", actif: true, createdAt: new Date(), updatedAt: new Date() },
];
(db.listEtablissements as ReturnType<typeof vi.fn>).mockResolvedValue(mockEtabs);
const { ctx } = createAuthCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.etablissements.list();
expect(result).toHaveLength(2);
expect(result[0]?.code).toBe("ETB001");
expect(result[1]?.code).toBe("ETB002");
});
it("lève une erreur UNAUTHORIZED si non authentifié", async () => {
const { ctx } = createPublicCtx();
const caller = appRouter.createCaller(ctx);
await expect(caller.etablissements.list()).rejects.toThrow();
});
});
// ─── Tests users.list (admin only) ───────────────────────────────────────────
describe("users.list", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne la liste des utilisateurs pour un admin", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
const mockUsers = [
makeUser({ id: 1, login: "admin", role: "admin" }),
makeUser({ id: 2, login: "jdupont", role: "standard" }),
];
(db.listUsers as ReturnType<typeof vi.fn>).mockResolvedValue(mockUsers);
const { ctx } = createAuthCtx({ role: "admin" });
const caller = appRouter.createCaller(ctx);
const result = await caller.users.list();
expect(result).toHaveLength(2);
expect(result[0]?.role).toBe("admin");
});
it("lève FORBIDDEN pour un utilisateur standard", async () => {
const { ctx } = createAuthCtx({ role: "standard" });
const caller = appRouter.createCaller(ctx);
await expect(caller.users.list()).rejects.toThrow();
});
it("lève FORBIDDEN pour un utilisateur readonly", async () => {
const { ctx } = createAuthCtx({ role: "readonly" });
const caller = appRouter.createCaller(ctx);
await expect(caller.users.list()).rejects.toThrow();
});
});
// ─── Tests parametres.get ─────────────────────────────────────────────────────
describe("parametres.get", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retourne les paramètres sous forme d'objet clé-valeur", async () => {
const { db } = await import("./db").then(m => ({ db: m }));
(db.getParametres as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, cle: "seuil_fixes_ans", valeur: "5", updatedAt: new Date() },
{ id: 2, cle: "cout_fixe", valeur: "850", updatedAt: new Date() },
]);
const { ctx } = createAuthCtx();
const caller = appRouter.createCaller(ctx);
const result = await caller.parametres.get();
expect(result).toEqual({ seuil_fixes_ans: "5", cout_fixe: "850" });
});
});

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import type { User } from "../drizzle/schema";
type CookieCall = {
name: string;
options: Record<string, unknown>;
};
function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {
const clearedCookies: CookieCall[] = [];
const user: User = {
id: 1,
login: "admin",
email: "admin@itinova.fr",
passwordHash: "$2a$10$hashedpassword",
firstName: "Admin",
lastName: "Itinova",
role: "admin",
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: null,
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: (name: string, options: Record<string, unknown>) => {
clearedCookies.push({ name, options });
},
} as TrpcContext["res"],
};
return { ctx, clearedCookies };
}
describe("auth.logout", () => {
it("clears the session cookie and reports success", async () => {
const { ctx, clearedCookies } = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
httpOnly: true,
path: "/",
});
});
});

310
server/db.ts Normal file
View File

@@ -0,0 +1,310 @@
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
capexLignes,
etablissements,
InsertCapexLigne,
InsertEtablissement,
InsertOpexMontantEtab,
InsertOpexPoste,
InsertUser,
inventaireMeta,
inventairePostes,
opexMontantsEtab,
opexPostes,
opexValidated,
parametresApp,
userEtablissements,
users,
} from "../drizzle/schema";
let _db: ReturnType<typeof drizzle> | null = null;
// Lazily create the drizzle instance so local tooling can run without a DB.
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
_db = drizzle(process.env.DATABASE_URL);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
// ─────────────────────────────────────────────────────────────────────────────
// USERS
// ─────────────────────────────────────────────────────────────────────────────
export async function getUserByLogin(login: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db
.select()
.from(users)
.where(eq(users.login, login))
.limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function getUserById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function createUser(user: InsertUser) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [result] = await db.insert(users).values(user);
return result;
}
export async function updateUser(id: number, data: Partial<InsertUser>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users).set(data).where(eq(users.id, id));
}
export async function listUsers() {
const db = await getDb();
if (!db) return [];
return db.select().from(users);
}
export async function deleteUser(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(users).where(eq(users.id, id));
}
export async function updateLastSignedIn(id: number) {
const db = await getDb();
if (!db) return;
await db
.update(users)
.set({ lastSignedIn: new Date() })
.where(eq(users.id, id));
}
// ─────────────────────────────────────────────────────────────────────────────
// ÉTABLISSEMENTS
// ─────────────────────────────────────────────────────────────────────────────
export async function listEtablissements() {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements);
}
export async function upsertEtablissement(etab: InsertEtablissement) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(etablissements)
.values(etab)
.onDuplicateKeyUpdate({
set: {
nom: etab.nom,
groupe: etab.groupe,
ville: etab.ville,
actif: etab.actif,
},
});
}
export async function deleteEtablissement(code: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(etablissements).where(eq(etablissements.code, code));
}
// ─────────────────────────────────────────────────────────────────────────────
// PARAMÈTRES
// ─────────────────────────────────────────────────────────────────────────────
export async function getParametres() {
const db = await getDb();
if (!db) return [];
return db.select().from(parametresApp);
}
export async function setParametre(cle: string, valeur: string) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(parametresApp)
.values({ cle, valeur })
.onDuplicateKeyUpdate({ set: { valeur } });
}
// ─────────────────────────────────────────────────────────────────────────────
// OPEX
// ─────────────────────────────────────────────────────────────────────────────
export async function getOpexPostes(annee: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(opexPostes)
.where(eq(opexPostes.annee, annee));
}
export async function upsertOpexPoste(poste: InsertOpexPoste) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (poste.id) {
await db
.update(opexPostes)
.set(poste)
.where(eq(opexPostes.id, poste.id));
} else {
await db.insert(opexPostes).values(poste);
}
}
export async function insertOpexPostes(postes: InsertOpexPoste[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (postes.length === 0) return;
await db.insert(opexPostes).values(postes);
}
export async function getOpexMontantsEtab(annee: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(opexMontantsEtab)
.where(eq(opexMontantsEtab.annee, annee));
}
export async function setOpexMontantEtab(data: InsertOpexMontantEtab) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(opexMontantsEtab)
.values(data)
.onDuplicateKeyUpdate({ set: { montant: data.montant } });
}
export async function insertOpexMontantsEtab(rows: InsertOpexMontantEtab[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
// Insert par batch de 100
for (let i = 0; i < rows.length; i += 100) {
await db.insert(opexMontantsEtab).values(rows.slice(i, i + 100));
}
}
export async function getOpexValidated(annee: number) {
const db = await getDb();
if (!db) return null;
const result = await db
.select()
.from(opexValidated)
.where(eq(opexValidated.annee, annee))
.limit(1);
return result.length > 0 ? result[0] : null;
}
export async function setOpexValidated(annee: number, userId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db
.insert(opexValidated)
.values({ annee, validatedBy: userId })
.onDuplicateKeyUpdate({ set: { validatedAt: new Date() } });
}
// ─────────────────────────────────────────────────────────────────────────────
// INVENTAIRE PC
// ─────────────────────────────────────────────────────────────────────────────
export async function getInventaire(annee: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(inventairePostes)
.where(eq(inventairePostes.annee, annee));
}
export async function getInventaireMeta(annee: number) {
const db = await getDb();
if (!db) return null;
const result = await db
.select()
.from(inventaireMeta)
.where(eq(inventaireMeta.annee, annee))
.limit(1);
return result.length > 0 ? result[0] : null;
}
export async function importInventaire(
annee: number,
postes: typeof inventairePostes.$inferInsert[],
meta: { filename: string; nbEtablissements: number; nbFixes: number; nbPortables: number }
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
// Supprimer l'inventaire existant pour cette année
await db.delete(inventairePostes).where(eq(inventairePostes.annee, annee));
// Insérer les nouveaux postes par batch
if (postes.length > 0) {
for (let i = 0; i < postes.length; i += 200) {
await db.insert(inventairePostes).values(postes.slice(i, i + 200));
}
}
// Upsert meta
await db
.insert(inventaireMeta)
.values({ annee, ...meta })
.onDuplicateKeyUpdate({ set: { ...meta, dateImport: new Date() } });
}
// ─────────────────────────────────────────────────────────────────────────────
// CAPEX
// ─────────────────────────────────────────────────────────────────────────────
export async function getCapexLignes(annee: number, etablissementCode: string) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(capexLignes)
.where(
and(
eq(capexLignes.annee, annee),
eq(capexLignes.etablissementCode, etablissementCode)
)
);
}
export async function saveCapexLignes(
annee: number,
etablissementCode: string,
lignes: { cle: string; montant: string | null }[]
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
for (const ligne of lignes) {
await db
.insert(capexLignes)
.values({ annee, etablissementCode, cle: ligne.cle, montant: ligne.montant })
.onDuplicateKeyUpdate({ set: { montant: ligne.montant } });
}
}
export async function insertCapexLignes(rows: InsertCapexLigne[]) {
const db = await getDb();
if (!db) throw new Error("Database not available");
if (rows.length === 0) return;
for (let i = 0; i < rows.length; i += 100) {
await db.insert(capexLignes).values(rows.slice(i, i + 100));
}
}

183
server/routers.ts Normal file
View File

@@ -0,0 +1,183 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { TRPCError } from "@trpc/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import * as db from "./db";
import { getSessionCookieOptions } from "./_core/cookies";
import { sdk } from "./_core/sdk";
import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc";
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role === "readonly") {
throw new TRPCError({ code: "FORBIDDEN", message: "Accès en lecture seule" });
}
return next({ ctx });
});
export const appRouter = router({
system: systemRouter,
auth: router({
login: publicProcedure
.input(z.object({ login: z.string().min(1), password: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const user = await db.getUserByLogin(input.login);
if (!user || !user.isActive) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" });
}
const valid = await bcrypt.compare(input.password, user.passwordHash);
if (!valid) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" });
}
await db.updateLastSignedIn(user.id);
const token = await sdk.createSessionToken(user.id, user.login, user.role);
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, token, { ...cookieOptions, maxAge: ONE_YEAR_MS });
return { id: user.id, login: user.login, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role };
}),
me: publicProcedure.query((opts) => {
const u = opts.ctx.user;
if (!u) return null;
return { id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive };
}),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
}),
users: router({
list: adminProcedure.query(async () => {
const list = await db.listUsers();
return list.map((u) => ({ id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive, createdAt: u.createdAt, lastSignedIn: u.lastSignedIn }));
}),
create: adminProcedure
.input(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") }))
.mutation(async ({ input }) => {
const existing = await db.getUserByLogin(input.login);
if (existing) throw new TRPCError({ code: "CONFLICT", message: "Ce login existe déjà" });
const passwordHash = await bcrypt.hash(input.password, 10);
await db.createUser({ login: input.login, passwordHash, email: input.email ?? null, firstName: input.firstName ?? null, lastName: input.lastName ?? null, role: input.role, isActive: true });
return { success: true };
}),
update: adminProcedure
.input(z.object({ id: z.number(), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).optional(), isActive: z.boolean().optional(), password: z.string().min(6).optional() }))
.mutation(async ({ input }) => {
const { id, password, ...rest } = input;
const data: Record<string, unknown> = { ...rest };
if (password) data.passwordHash = await bcrypt.hash(password, 10);
await db.updateUser(id, data as Parameters<typeof db.updateUser>[1]);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => { await db.deleteUser(input.id); return { success: true }; }),
importBulk: adminProcedure
.input(z.array(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") })))
.mutation(async ({ input }) => {
let created = 0; let skipped = 0;
for (const u of input) {
const existing = await db.getUserByLogin(u.login);
if (existing) { skipped++; continue; }
const passwordHash = await bcrypt.hash(u.password, 10);
await db.createUser({ login: u.login, passwordHash, email: u.email ?? null, firstName: u.firstName ?? null, lastName: u.lastName ?? null, role: u.role, isActive: true });
created++;
}
return { created, skipped };
}),
}),
etablissements: router({
list: protectedProcedure.query(async () => db.listEtablissements()),
upsert: adminProcedure
.input(z.object({ code: z.string().min(1), nom: z.string().min(1), groupe: z.string().optional().nullable(), ville: z.string().optional().nullable(), actif: z.boolean().default(true) }))
.mutation(async ({ input }) => { await db.upsertEtablissement(input); return { success: true }; }),
delete: adminProcedure
.input(z.object({ code: z.string() }))
.mutation(async ({ input }) => { await db.deleteEtablissement(input.code); return { success: true }; }),
}),
parametres: router({
get: protectedProcedure.query(async () => {
const rows = await db.getParametres();
return Object.fromEntries(rows.map((r) => [r.cle, r.valeur]));
}),
set: adminProcedure
.input(z.object({ cle: z.string(), valeur: z.string() }))
.mutation(async ({ input }) => { await db.setParametre(input.cle, input.valeur); return { success: true }; }),
setBulk: adminProcedure
.input(z.record(z.string(), z.string()))
.mutation(async ({ input }) => {
for (const [cle, valeur] of Object.entries(input)) await db.setParametre(cle, valeur);
return { success: true };
}),
}),
opex: router({
getPostes: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexPostes(input.annee)),
upsertPoste: writeProcedure
.input(z.object({ id: z.number().optional(), annee: z.number(), colIdx: z.number(), libelle: z.string(), libelleCourt: z.string().optional().nullable(), libelleDetail: z.string().optional().nullable(), fournisseur: z.string().optional().nullable(), categorie: z.string().optional().nullable(), type: z.string().optional().nullable(), facturation: z.string().optional().nullable(), modeVentilation: z.string().optional().nullable(), compte: z.string().optional().nullable(), detail: z.string().optional().nullable(), budgetN1: z.string().optional().nullable(), montant: z.string().optional().nullable(), isCustom: z.boolean().optional() }))
.mutation(async ({ input }) => { await db.upsertOpexPoste(input as Parameters<typeof db.upsertOpexPoste>[0]); return { success: true }; }),
getMontantsEtab: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexMontantsEtab(input.annee)),
setMontantEtab: writeProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string(), libellePoste: z.string(), montant: z.string().nullable() }))
.mutation(async ({ input }) => { await db.setOpexMontantEtab(input as Parameters<typeof db.setOpexMontantEtab>[0]); return { success: true }; }),
getValidated: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => db.getOpexValidated(input.annee)),
validate: adminProcedure
.input(z.object({ annee: z.number() }))
.mutation(async ({ input, ctx }) => { await db.setOpexValidated(input.annee, ctx.user.id); return { success: true }; }),
}),
inventaire: router({
get: protectedProcedure
.input(z.object({ annee: z.number() }))
.query(async ({ input }) => {
const postes = await db.getInventaire(input.annee);
const meta = await db.getInventaireMeta(input.annee);
return { postes, meta };
}),
import: writeProcedure
.input(z.object({ annee: z.number(), filename: z.string(), postes: z.array(z.object({ etablissementCode: z.string(), libelle: z.string().optional().nullable(), typePoste: z.enum(["fixe", "portable"]), dateRef: z.string().optional().nullable(), ageAns: z.string().optional().nullable(), modele: z.string().optional().nullable(), fabricant: z.string().optional().nullable() })) }))
.mutation(async ({ input }) => {
const nbFixes = input.postes.filter((p) => p.typePoste === "fixe").length;
const nbPortables = input.postes.filter((p) => p.typePoste === "portable").length;
const etabSet = new Set(input.postes.map((p) => p.etablissementCode));
await db.importInventaire(input.annee, input.postes.map((p) => ({ ...p, annee: input.annee })), { filename: input.filename, nbEtablissements: etabSet.size, nbFixes, nbPortables });
return { success: true, nbFixes, nbPortables, nbEtablissements: etabSet.size };
}),
}),
capex: router({
get: protectedProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string() }))
.query(async ({ input }) => db.getCapexLignes(input.annee, input.etablissementCode)),
save: writeProcedure
.input(z.object({ annee: z.number(), etablissementCode: z.string(), lignes: z.array(z.object({ cle: z.string(), montant: z.string().nullable() })) }))
.mutation(async ({ input }) => { await db.saveCapexLignes(input.annee, input.etablissementCode, input.lignes); return { success: true }; }),
}),
});
export type AppRouter = typeof appRouter;

97
server/storage.ts Normal file
View File

@@ -0,0 +1,97 @@
// Preconfigured storage helpers for Manus WebDev templates
// Uploads via Forge Server presigned URL to S3 (PUT direct).
// Downloads return /manus-storage/{key} paths served via 307 redirect.
import { ENV } from "./_core/env";
function getForgeConfig() {
const forgeUrl = ENV.forgeApiUrl;
const forgeKey = ENV.forgeApiKey;
if (!forgeUrl || !forgeKey) {
throw new Error(
"Storage config missing: set BUILT_IN_FORGE_API_URL and BUILT_IN_FORGE_API_KEY",
);
}
return { forgeUrl: forgeUrl.replace(/\/+$/, ""), forgeKey };
}
function normalizeKey(relKey: string): string {
return relKey.replace(/^\/+/, "");
}
function appendHashSuffix(relKey: string): string {
const hash = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
const lastDot = relKey.lastIndexOf(".");
if (lastDot === -1) return `${relKey}_${hash}`;
return `${relKey.slice(0, lastDot)}_${hash}${relKey.slice(lastDot)}`;
}
export async function storagePut(
relKey: string,
data: Buffer | Uint8Array | string,
contentType = "application/octet-stream",
): Promise<{ key: string; url: string }> {
const { forgeUrl, forgeKey } = getForgeConfig();
const key = appendHashSuffix(normalizeKey(relKey));
// 1. Get presigned PUT URL from Forge
const presignUrl = new URL("v1/storage/presign/put", forgeUrl + "/");
presignUrl.searchParams.set("path", key);
const presignResp = await fetch(presignUrl, {
headers: { Authorization: `Bearer ${forgeKey}` },
});
if (!presignResp.ok) {
const msg = await presignResp.text().catch(() => presignResp.statusText);
throw new Error(`Storage presign failed (${presignResp.status}): ${msg}`);
}
const { url: s3Url } = (await presignResp.json()) as { url: string };
if (!s3Url) throw new Error("Forge returned empty presign URL");
// 2. PUT file directly to S3
const blob =
typeof data === "string"
? new Blob([data], { type: contentType })
: new Blob([data as any], { type: contentType });
const uploadResp = await fetch(s3Url, {
method: "PUT",
headers: { "Content-Type": contentType },
body: blob,
});
if (!uploadResp.ok) {
throw new Error(`Storage upload to S3 failed (${uploadResp.status})`);
}
return { key, url: `/manus-storage/${key}` };
}
export async function storageGet(relKey: string): Promise<{ key: string; url: string }> {
const key = normalizeKey(relKey);
return { key, url: `/manus-storage/${key}` };
}
export async function storageGetSignedUrl(relKey: string): Promise<string> {
const { forgeUrl, forgeKey } = getForgeConfig();
const key = normalizeKey(relKey);
const getUrl = new URL("v1/storage/presign/get", forgeUrl + "/");
getUrl.searchParams.set("path", key);
const resp = await fetch(getUrl, {
headers: { Authorization: `Bearer ${forgeKey}` },
});
if (!resp.ok) {
const msg = await resp.text().catch(() => resp.statusText);
throw new Error(`Storage signed URL failed (${resp.status}): ${msg}`);
}
const { url } = (await resp.json()) as { url: string };
return url;
}

19
shared/_core/errors.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Base HTTP error class with status code.
* Throw this from route handlers to send specific HTTP errors.
*/
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "HttpError";
}
}
// Convenience constructors
export const BadRequestError = (msg: string) => new HttpError(400, msg);
export const UnauthorizedError = (msg: string) => new HttpError(401, msg);
export const ForbiddenError = (msg: string) => new HttpError(403, msg);
export const NotFoundError = (msg: string) => new HttpError(404, msg);

View File

@@ -1,2 +1,5 @@
export const COOKIE_NAME = "app_session_id";
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
export const AXIOS_TIMEOUT_MS = 30_000;
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';

7
shared/types.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Unified type exports
* Import shared types from this single entry point.
*/
export type * from "../drizzle/schema";
export * from "./_core/errors";

42
todo.md Normal file
View File

@@ -0,0 +1,42 @@
# Budget SI Itinova — TODO
## Migration vers base de données (tRPC + auth locale)
- [x] Schéma Drizzle complet (users, etablissements, inventaire, opex, capex, parametres_app)
- [x] Migration base de données (pnpm db:push — 10 tables créées)
- [x] Seed compte admin par défaut (admin / Itinova2027!)
- [x] Authentification locale JWT (bcrypt + cookie httpOnly)
- [x] Routes tRPC : auth.login, auth.me, auth.logout
- [x] Routes tRPC : users.list, users.create, users.update, users.delete, users.importBulk
- [x] Routes tRPC : etablissements.list, etablissements.upsert, etablissements.delete
- [x] Routes tRPC : parametres.get, parametres.set, parametres.setBulk
- [x] Routes tRPC : opex.getPostes, opex.upsertPoste, opex.getMontantsEtab, opex.setMontantEtab, opex.getValidated, opex.validate
- [x] Routes tRPC : inventaire.get, inventaire.import
- [x] Routes tRPC : capex.get, capex.save
- [x] Page de connexion avec branding Itinova/Santinova
- [x] Protection des routes (ProtectedRoute dans App.tsx)
- [x] Hook useAuth branché sur tRPC (auth.me)
- [x] Bouton déconnexion dans la sidebar
- [x] Page Paramètres — onglet Établissements branché sur tRPC
- [x] Page Paramètres — onglet Utilisateurs branché sur tRPC
- [x] ImportModal — import inventaire sauvegardé en BDD via tRPC
- [x] ImportModal — import établissements sauvegardé en BDD via tRPC
- [x] ImportModal — import utilisateurs sauvegardé en BDD via tRPC
- [x] Tests Vitest (14 tests passent) : auth.logout, auth.me, auth.login, etablissements.list, users.list, parametres.get
## Fonctionnalités existantes (localStorage — conservées)
- [x] Page Renouvellement PC (Home.tsx) — données inventaire depuis localStorage
- [x] Page Budget 2027 — données depuis localStorage
- [x] Page DSI CAPEX — données depuis localStorage
- [x] Page DSI OPEX — données depuis localStorage
- [x] Pages Santinova, Soins Santé, St-Exupéry — données depuis localStorage
- [x] Contexte Paramètres (seuils, coûts) — localStorage
- [x] Contexte Année — localStorage
## Améliorations futures
- [ ] Migrer les pages Budget2027, DsiCapex, DsiOpex pour lire depuis la BDD via tRPC
- [ ] Gestion des droits par établissement (userEtablissements)
- [ ] Export PDF/Excel des budgets
- [ ] Historique des modifications (audit log)

View File

@@ -163,13 +163,12 @@ export default defineConfig({
},
envDir: path.resolve(import.meta.dirname),
root: path.resolve(import.meta.dirname, "client"),
publicDir: path.resolve(import.meta.dirname, "client", "public"),
build: {
outDir: path.resolve(import.meta.dirname, "dist/public"),
emptyOutDir: true,
},
server: {
port: 3000,
strictPort: false, // Will find next available port if 3000 is busy
host: true,
allowedHosts: [
".manuspre.computer",

19
vitest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import path from "path";
const templateRoot = path.resolve(import.meta.dirname);
export default defineConfig({
root: templateRoot,
resolve: {
alias: {
"@": path.resolve(templateRoot, "client", "src"),
"@shared": path.resolve(templateRoot, "shared"),
"@assets": path.resolve(templateRoot, "attached_assets"),
},
},
test: {
environment: "node",
include: ["server/**/*.test.ts", "server/**/*.spec.ts"],
},
});