CognxSafeTrack commited on
Commit ·
2ab1980
1
Parent(s): 6248bf4
feat: migrate to multi-tenant SaaS architecture with JWT auth and BullMQ notifications
Browse files- apps/admin/src/App.tsx +68 -27
- apps/admin/src/lib/api.ts +8 -4
- apps/admin/src/lib/auth.tsx +28 -10
- apps/admin/src/lib/tenant.tsx +23 -2
- apps/admin/src/pages/AnalyticsPage.tsx +7 -16
- apps/admin/src/pages/ClientsManagementView.tsx +10 -8
- apps/admin/src/pages/DashboardPage.tsx +3 -6
- apps/admin/src/pages/LiveFeed.tsx +23 -37
- apps/admin/src/pages/LoginPage.tsx +79 -22
- apps/admin/src/pages/TrackDaysPage.tsx +9 -13
- apps/admin/src/pages/TrackFormPage.tsx +7 -11
- apps/admin/src/pages/TrackListPage.tsx +7 -12
- apps/admin/src/pages/TrainingLab.tsx +11 -12
- apps/admin/src/pages/UserListPage.tsx +6 -10
- apps/api/package.json +3 -0
- apps/api/src/index.ts +51 -19
- apps/api/src/routes/auth.ts +76 -0
- apps/api/src/routes/organizations.ts +60 -24
- apps/api/src/services/auth.ts +42 -0
- apps/api/src/services/email.ts +82 -0
- apps/api/src/services/queue.ts +15 -0
- apps/whatsapp-worker/src/handlers/EmailHandler.ts +25 -0
- apps/whatsapp-worker/src/index.ts +29 -1
- apps/whatsapp-worker/src/services/email.ts +53 -0
- apps/whatsapp-worker/src/services/whatsapp-logic.ts +3 -1
- packages/database/prisma/schema.prisma +73 -63
- packages/shared-types/src/index.ts +17 -2
- packages/shared-types/src/organization.ts +20 -0
- pnpm-lock.yaml +149 -0
apps/admin/src/App.tsx
CHANGED
|
@@ -22,61 +22,101 @@ import { useTenant } from './lib/tenant';
|
|
| 22 |
import { API_URL } from './lib/api';
|
| 23 |
|
| 24 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
| 25 |
-
const {
|
| 26 |
-
if (!
|
| 27 |
return <>{children}</>;
|
| 28 |
}
|
| 29 |
|
| 30 |
function AppShell() {
|
| 31 |
-
const { logout,
|
| 32 |
-
const { selectedOrgId, setSelectedOrgId } = useTenant();
|
| 33 |
const [orgs, setOrgs] = React.useState<any[]>([]);
|
| 34 |
|
|
|
|
|
|
|
| 35 |
React.useEffect(() => {
|
| 36 |
-
if (!
|
| 37 |
fetch(`${API_URL}/v1/organizations`, {
|
| 38 |
-
headers: { 'Authorization': `Bearer ${
|
| 39 |
}).then(r => r.json()).then(setOrgs).catch(console.error);
|
| 40 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
const
|
| 43 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 44 |
{ to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
|
| 45 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
|
| 46 |
{ to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
|
| 47 |
-
{ to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" /> },
|
| 48 |
-
{ to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" /> },
|
| 49 |
{ to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
|
| 50 |
{ to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
|
| 51 |
];
|
|
|
|
|
|
|
| 52 |
|
|
|
|
|
|
|
| 53 |
return (
|
| 54 |
<div className="min-h-screen bg-gray-50 flex">
|
| 55 |
-
<aside className="w-
|
| 56 |
-
<div className="text-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
className="w-full bg-slate-800 text-slate-200 text-xs px-3 py-2.5 rounded-xl outline-none focus:ring-1 focus:ring-slate-600 appearance-none cursor-pointer"
|
| 64 |
-
>
|
| 65 |
-
<option value="">Sélectionner...</option>
|
| 66 |
-
{orgs.map(o => (
|
| 67 |
-
<option key={o.id} value={o.id}>{o.name}</option>
|
| 68 |
-
))}
|
| 69 |
-
</select>
|
| 70 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
<nav className="space-y-1 flex-1">
|
| 73 |
{navItems.map(n => (
|
| 74 |
-
<Link
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
{n.icon}{n.label}
|
| 76 |
</Link>
|
| 77 |
))}
|
| 78 |
</nav>
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
</aside>
|
| 81 |
<main className="flex-1 overflow-auto">
|
| 82 |
<Routes>
|
|
@@ -93,6 +133,7 @@ function AppShell() {
|
|
| 93 |
<Route path="/users" element={<UserListPage />} />
|
| 94 |
<Route path="/settings" element={<SettingsPage />} />
|
| 95 |
<Route path="/onboarding" element={<OnboardingWizard />} />
|
|
|
|
| 96 |
</Routes>
|
| 97 |
</main>
|
| 98 |
</div>
|
|
|
|
| 22 |
import { API_URL } from './lib/api';
|
| 23 |
|
| 24 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
| 25 |
+
const { token } = useAuth();
|
| 26 |
+
if (!token) return <Navigate to="/login" replace />;
|
| 27 |
return <>{children}</>;
|
| 28 |
}
|
| 29 |
|
| 30 |
function AppShell() {
|
| 31 |
+
const { logout, token, user } = useAuth();
|
| 32 |
+
const { selectedOrgId, setSelectedOrgId, isSubdomain, currentOrg } = useTenant();
|
| 33 |
const [orgs, setOrgs] = React.useState<any[]>([]);
|
| 34 |
|
| 35 |
+
const isSuperAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
|
| 36 |
+
|
| 37 |
React.useEffect(() => {
|
| 38 |
+
if (!token || !isSuperAdmin) return;
|
| 39 |
fetch(`${API_URL}/v1/organizations`, {
|
| 40 |
+
headers: { 'Authorization': `Bearer ${token}` }
|
| 41 |
}).then(r => r.json()).then(setOrgs).catch(console.error);
|
| 42 |
+
}, [token, isSuperAdmin]);
|
| 43 |
+
|
| 44 |
+
// Force org selection for Org Admin
|
| 45 |
+
React.useEffect(() => {
|
| 46 |
+
if (user?.organizationId && !isSuperAdmin) {
|
| 47 |
+
setSelectedOrgId(user.organizationId);
|
| 48 |
+
}
|
| 49 |
+
}, [user, isSuperAdmin, setSelectedOrgId]);
|
| 50 |
|
| 51 |
+
const allNavItems = [
|
| 52 |
{ to: '/', label: 'Dashboard', icon: <BarChart2 className="w-4 h-4" /> },
|
| 53 |
{ to: '/analytics', label: 'Statistiques', icon: <TrendingUp className="w-4 h-4 text-amber-500" /> },
|
| 54 |
{ to: '/content', label: 'Parcours', icon: <BookOpen className="w-4 h-4" /> },
|
| 55 |
{ to: '/live-feed', label: 'Modération', icon: <Mic className="w-4 h-4 text-emerald-500" /> },
|
| 56 |
+
{ to: '/clients', label: 'Clients B2B', icon: <Building2 className="w-4 h-4 text-indigo-400" />, superOnly: true },
|
| 57 |
+
{ to: '/training', label: 'Training Lab', icon: <Activity className="w-4 h-4 text-purple-400" />, superOnly: true },
|
| 58 |
{ to: '/users', label: 'Utilisateurs', icon: <Users className="w-4 h-4" /> },
|
| 59 |
{ to: '/settings', label: 'Paramètres', icon: <Lightbulb className="w-4 h-4" /> },
|
| 60 |
];
|
| 61 |
+
|
| 62 |
+
const navItems = allNavItems.filter(item => !item.superOnly || isSuperAdmin);
|
| 63 |
|
| 64 |
+
const brandingColor = currentOrg?.brandingData?.primaryColor || '#0f172a';
|
| 65 |
+
|
| 66 |
return (
|
| 67 |
<div className="min-h-screen bg-gray-50 flex">
|
| 68 |
+
<aside className="w-64 bg-slate-900 text-white p-6 flex flex-col shrink-0">
|
| 69 |
+
<div className="text-xl font-bold mb-8 flex items-center gap-3">
|
| 70 |
+
{currentOrg?.brandingData?.logoUrl ? (
|
| 71 |
+
<img src={currentOrg.brandingData.logoUrl} className="h-8 w-8 object-contain" alt="Logo" />
|
| 72 |
+
) : (
|
| 73 |
+
<span className="text-2xl">🎓</span>
|
| 74 |
+
)}
|
| 75 |
+
<span className="truncate">{currentOrg?.name || 'EdTech Admin'}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
</div>
|
| 77 |
+
|
| 78 |
+
{isSuperAdmin && (
|
| 79 |
+
<div className="mb-8">
|
| 80 |
+
<label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">Gestion Multi-Tenant</label>
|
| 81 |
+
<select
|
| 82 |
+
value={selectedOrgId || ''}
|
| 83 |
+
onChange={e => setSelectedOrgId(e.target.value)}
|
| 84 |
+
className="w-full bg-slate-800 text-slate-200 text-xs px-3 py-2.5 rounded-xl outline-none focus:ring-1 focus:ring-slate-600 appearance-none cursor-pointer"
|
| 85 |
+
>
|
| 86 |
+
<option value="">Sélectionner une école...</option>
|
| 87 |
+
{orgs.map(o => (
|
| 88 |
+
<option key={o.id} value={o.id}>{o.name}</option>
|
| 89 |
+
))}
|
| 90 |
+
</select>
|
| 91 |
+
</div>
|
| 92 |
+
)}
|
| 93 |
|
| 94 |
<nav className="space-y-1 flex-1">
|
| 95 |
{navItems.map(n => (
|
| 96 |
+
<Link
|
| 97 |
+
key={n.to}
|
| 98 |
+
to={n.to}
|
| 99 |
+
className="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-white/10 transition"
|
| 100 |
+
>
|
| 101 |
{n.icon}{n.label}
|
| 102 |
</Link>
|
| 103 |
))}
|
| 104 |
</nav>
|
| 105 |
+
|
| 106 |
+
<div className="pt-6 mt-6 border-t border-slate-800">
|
| 107 |
+
<div className="flex items-center gap-3 px-4 mb-4">
|
| 108 |
+
<div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-xs">
|
| 109 |
+
{user?.name?.[0] || 'U'}
|
| 110 |
+
</div>
|
| 111 |
+
<div className="flex-1 min-w-0">
|
| 112 |
+
<p className="text-sm font-bold truncate">{user?.name}</p>
|
| 113 |
+
<p className="text-[10px] text-slate-500 truncate capitalize">{user?.role?.toLowerCase()}</p>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
<button onClick={logout} className="w-full flex items-center gap-3 px-4 py-2 text-xs text-slate-500 hover:text-white transition">
|
| 117 |
+
🔓 Se déconnecter
|
| 118 |
+
</button>
|
| 119 |
+
</div>
|
| 120 |
</aside>
|
| 121 |
<main className="flex-1 overflow-auto">
|
| 122 |
<Routes>
|
|
|
|
| 133 |
<Route path="/users" element={<UserListPage />} />
|
| 134 |
<Route path="/settings" element={<SettingsPage />} />
|
| 135 |
<Route path="/onboarding" element={<OnboardingWizard />} />
|
| 136 |
+
<Route path="/reset-password" element={<div className="p-8">Page de réinitialisation (À implémenter)</div>} />
|
| 137 |
</Routes>
|
| 138 |
</main>
|
| 139 |
</div>
|
apps/admin/src/lib/api.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
| 2 |
|
| 3 |
-
export const ah = (
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
| 2 |
|
| 3 |
+
export const ah = (token: string, orgId?: string | null) => {
|
| 4 |
+
const headers: any = {
|
| 5 |
+
'Authorization': `Bearer ${token}`,
|
| 6 |
+
'Content-Type': 'application/json'
|
| 7 |
+
};
|
| 8 |
+
if (orgId) headers['x-organization-id'] = orgId;
|
| 9 |
+
return headers;
|
| 10 |
+
};
|
apps/admin/src/lib/auth.tsx
CHANGED
|
@@ -1,28 +1,46 @@
|
|
| 1 |
-
import React, { useState, createContext, useContext } from 'react';
|
|
|
|
| 2 |
|
| 3 |
-
const SESSION_KEY = '
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
login: () => {},
|
| 8 |
logout: () => {}
|
| 9 |
});
|
| 10 |
|
| 11 |
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 12 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
const login = (
|
| 15 |
-
sessionStorage.setItem(SESSION_KEY,
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
};
|
| 18 |
|
| 19 |
const logout = () => {
|
| 20 |
sessionStorage.removeItem(SESSION_KEY);
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
return (
|
| 25 |
-
<AuthContext.Provider value={{
|
| 26 |
{children}
|
| 27 |
</AuthContext.Provider>
|
| 28 |
);
|
|
|
|
| 1 |
+
import React, { useState, createContext, useContext, useEffect } from 'react';
|
| 2 |
+
import { User } from '@repo/shared-types';
|
| 3 |
|
| 4 |
+
const SESSION_KEY = 'edtech_admin_token';
|
| 5 |
+
const USER_KEY = 'edtech_admin_user';
|
| 6 |
|
| 7 |
+
interface AuthContextType {
|
| 8 |
+
token: string | null;
|
| 9 |
+
user: User | null;
|
| 10 |
+
login: (token: string, user: User) => void;
|
| 11 |
+
logout: () => void;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export const AuthContext = createContext<AuthContextType>({
|
| 15 |
+
token: null,
|
| 16 |
+
user: null,
|
| 17 |
login: () => {},
|
| 18 |
logout: () => {}
|
| 19 |
});
|
| 20 |
|
| 21 |
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 22 |
+
const [token, setToken] = useState<string | null>(() => sessionStorage.getItem(SESSION_KEY));
|
| 23 |
+
const [user, setUser] = useState<User | null>(() => {
|
| 24 |
+
const saved = sessionStorage.getItem(USER_KEY);
|
| 25 |
+
return saved ? JSON.parse(saved) : null;
|
| 26 |
+
});
|
| 27 |
|
| 28 |
+
const login = (t: string, u: User) => {
|
| 29 |
+
sessionStorage.setItem(SESSION_KEY, t);
|
| 30 |
+
sessionStorage.setItem(USER_KEY, JSON.stringify(u));
|
| 31 |
+
setToken(t);
|
| 32 |
+
setUser(u);
|
| 33 |
};
|
| 34 |
|
| 35 |
const logout = () => {
|
| 36 |
sessionStorage.removeItem(SESSION_KEY);
|
| 37 |
+
sessionStorage.removeItem(USER_KEY);
|
| 38 |
+
setToken(null);
|
| 39 |
+
setUser(null);
|
| 40 |
};
|
| 41 |
|
| 42 |
return (
|
| 43 |
+
<AuthContext.Provider value={{ token, user, login, logout }}>
|
| 44 |
{children}
|
| 45 |
</AuthContext.Provider>
|
| 46 |
);
|
apps/admin/src/lib/tenant.tsx
CHANGED
|
@@ -1,30 +1,51 @@
|
|
| 1 |
import React, { useState, createContext, useContext, useEffect } from 'react';
|
|
|
|
|
|
|
| 2 |
|
| 3 |
const TENANT_KEY = 'edtech_selected_org';
|
| 4 |
|
| 5 |
interface TenantContextType {
|
| 6 |
selectedOrgId: string | null;
|
| 7 |
setSelectedOrgId: (id: string | null) => void;
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
export const TenantContext = createContext<TenantContextType>({
|
| 11 |
selectedOrgId: null,
|
| 12 |
-
setSelectedOrgId: () => {}
|
|
|
|
|
|
|
|
|
|
| 13 |
});
|
| 14 |
|
| 15 |
export function TenantProvider({ children }: { children: React.ReactNode }) {
|
| 16 |
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(() => sessionStorage.getItem(TENANT_KEY));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
if (selectedOrgId) {
|
| 20 |
sessionStorage.setItem(TENANT_KEY, selectedOrgId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
} else {
|
| 22 |
sessionStorage.removeItem(TENANT_KEY);
|
|
|
|
| 23 |
}
|
| 24 |
}, [selectedOrgId]);
|
| 25 |
|
| 26 |
return (
|
| 27 |
-
<TenantContext.Provider value={{ selectedOrgId, setSelectedOrgId }}>
|
| 28 |
{children}
|
| 29 |
</TenantContext.Provider>
|
| 30 |
);
|
|
|
|
| 1 |
import React, { useState, createContext, useContext, useEffect } from 'react';
|
| 2 |
+
import { API_URL } from './api';
|
| 3 |
+
import { Organization } from '@repo/shared-types';
|
| 4 |
|
| 5 |
const TENANT_KEY = 'edtech_selected_org';
|
| 6 |
|
| 7 |
interface TenantContextType {
|
| 8 |
selectedOrgId: string | null;
|
| 9 |
setSelectedOrgId: (id: string | null) => void;
|
| 10 |
+
currentOrg: Organization | null;
|
| 11 |
+
isSubdomain: boolean;
|
| 12 |
+
slug: string | null;
|
| 13 |
}
|
| 14 |
|
| 15 |
export const TenantContext = createContext<TenantContextType>({
|
| 16 |
selectedOrgId: null,
|
| 17 |
+
setSelectedOrgId: () => {},
|
| 18 |
+
currentOrg: null,
|
| 19 |
+
isSubdomain: false,
|
| 20 |
+
slug: null
|
| 21 |
});
|
| 22 |
|
| 23 |
export function TenantProvider({ children }: { children: React.ReactNode }) {
|
| 24 |
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(() => sessionStorage.getItem(TENANT_KEY));
|
| 25 |
+
const [currentOrg, setCurrentOrg] = useState<any | null>(null);
|
| 26 |
+
|
| 27 |
+
// Subdomain detection logic
|
| 28 |
+
const hostname = window.location.hostname;
|
| 29 |
+
const parts = hostname.split('.');
|
| 30 |
+
const isSubdomain = parts.length > 2 && parts[0] !== 'www' && parts[0] !== 'admin';
|
| 31 |
+
const slug = isSubdomain ? parts[0] : null;
|
| 32 |
|
| 33 |
useEffect(() => {
|
| 34 |
if (selectedOrgId) {
|
| 35 |
sessionStorage.setItem(TENANT_KEY, selectedOrgId);
|
| 36 |
+
// Fetch org details for branding
|
| 37 |
+
fetch(`${API_URL}/v1/organizations/${selectedOrgId}`)
|
| 38 |
+
.then(r => r.json())
|
| 39 |
+
.then(setCurrentOrg)
|
| 40 |
+
.catch(console.error);
|
| 41 |
} else {
|
| 42 |
sessionStorage.removeItem(TENANT_KEY);
|
| 43 |
+
setCurrentOrg(null);
|
| 44 |
}
|
| 45 |
}, [selectedOrgId]);
|
| 46 |
|
| 47 |
return (
|
| 48 |
+
<TenantContext.Provider value={{ selectedOrgId, setSelectedOrgId, currentOrg, isSubdomain, slug }}>
|
| 49 |
{children}
|
| 50 |
</TenantContext.Provider>
|
| 51 |
);
|
apps/admin/src/pages/AnalyticsPage.tsx
CHANGED
|
@@ -10,36 +10,27 @@ import {
|
|
| 10 |
} from 'lucide-react';
|
| 11 |
import { useAuth } from '../lib/auth';
|
| 12 |
import { useTenant } from '../lib/tenant';
|
| 13 |
-
import { API_URL } from '../lib/api';
|
| 14 |
|
| 15 |
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444'];
|
| 16 |
|
| 17 |
export default function AnalyticsPage() {
|
| 18 |
-
const {
|
| 19 |
const { selectedOrgId } = useTenant();
|
| 20 |
const [usage, setUsage] = useState<any>(null);
|
| 21 |
const [pedagogy, setPedagogy] = useState<any>(null);
|
| 22 |
const [loading, setLoading] = useState(true);
|
| 23 |
|
| 24 |
useEffect(() => {
|
| 25 |
-
if (!selectedOrgId || !
|
| 26 |
|
| 27 |
const fetchData = async () => {
|
| 28 |
setLoading(true);
|
| 29 |
try {
|
|
|
|
| 30 |
const [usageRes, pedagogyRes] = await Promise.all([
|
| 31 |
-
fetch(`${API_URL}/v1/analytics/usage`, {
|
| 32 |
-
|
| 33 |
-
'Authorization': `Bearer ${apiKey}`,
|
| 34 |
-
'x-organization-id': selectedOrgId
|
| 35 |
-
}
|
| 36 |
-
}),
|
| 37 |
-
fetch(`${API_URL}/v1/analytics/pedagogy`, {
|
| 38 |
-
headers: {
|
| 39 |
-
'Authorization': `Bearer ${apiKey}`,
|
| 40 |
-
'x-organization-id': selectedOrgId
|
| 41 |
-
}
|
| 42 |
-
})
|
| 43 |
]);
|
| 44 |
|
| 45 |
if (usageRes.ok) setUsage(await usageRes.json());
|
|
@@ -52,7 +43,7 @@ export default function AnalyticsPage() {
|
|
| 52 |
};
|
| 53 |
|
| 54 |
fetchData();
|
| 55 |
-
}, [selectedOrgId,
|
| 56 |
|
| 57 |
if (!selectedOrgId) {
|
| 58 |
return (
|
|
|
|
| 10 |
} from 'lucide-react';
|
| 11 |
import { useAuth } from '../lib/auth';
|
| 12 |
import { useTenant } from '../lib/tenant';
|
| 13 |
+
import { API_URL, ah } from '../lib/api';
|
| 14 |
|
| 15 |
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444'];
|
| 16 |
|
| 17 |
export default function AnalyticsPage() {
|
| 18 |
+
const { token } = useAuth();
|
| 19 |
const { selectedOrgId } = useTenant();
|
| 20 |
const [usage, setUsage] = useState<any>(null);
|
| 21 |
const [pedagogy, setPedagogy] = useState<any>(null);
|
| 22 |
const [loading, setLoading] = useState(true);
|
| 23 |
|
| 24 |
useEffect(() => {
|
| 25 |
+
if (!selectedOrgId || !token) return;
|
| 26 |
|
| 27 |
const fetchData = async () => {
|
| 28 |
setLoading(true);
|
| 29 |
try {
|
| 30 |
+
const h = ah(token, selectedOrgId);
|
| 31 |
const [usageRes, pedagogyRes] = await Promise.all([
|
| 32 |
+
fetch(`${API_URL}/v1/analytics/usage`, { headers: h }),
|
| 33 |
+
fetch(`${API_URL}/v1/analytics/pedagogy`, { headers: h })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
]);
|
| 35 |
|
| 36 |
if (usageRes.ok) setUsage(await usageRes.json());
|
|
|
|
| 43 |
};
|
| 44 |
|
| 45 |
fetchData();
|
| 46 |
+
}, [selectedOrgId, token]);
|
| 47 |
|
| 48 |
if (!selectedOrgId) {
|
| 49 |
return (
|
apps/admin/src/pages/ClientsManagementView.tsx
CHANGED
|
@@ -31,7 +31,7 @@ interface PersonalityModalProps {
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export default function ClientsManagementView() {
|
| 34 |
-
const {
|
| 35 |
const [clients, setClients] = useState<Organization[]>([]);
|
| 36 |
const [loading, setLoading] = useState(true);
|
| 37 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
@@ -39,11 +39,12 @@ export default function ClientsManagementView() {
|
|
| 39 |
const [newOrgName, setNewOrgName] = useState('');
|
| 40 |
const [isCreating, setIsCreating] = useState(false);
|
| 41 |
const [showGuide, setShowGuide] = useState(false);
|
| 42 |
-
|
| 43 |
const fetchClients = async () => {
|
|
|
|
| 44 |
try {
|
| 45 |
const res = await fetch(`${API_URL}/v1/organizations`, {
|
| 46 |
-
headers: ah(
|
| 47 |
});
|
| 48 |
if (res.ok) {
|
| 49 |
const data = await res.json();
|
|
@@ -55,10 +56,10 @@ export default function ClientsManagementView() {
|
|
| 55 |
setLoading(false);
|
| 56 |
}
|
| 57 |
};
|
| 58 |
-
|
| 59 |
useEffect(() => {
|
| 60 |
fetchClients();
|
| 61 |
-
}, [
|
| 62 |
|
| 63 |
const handleCreateOrg = async (e: React.FormEvent) => {
|
| 64 |
e.preventDefault();
|
|
@@ -66,7 +67,7 @@ export default function ClientsManagementView() {
|
|
| 66 |
try {
|
| 67 |
const res = await fetch(`${API_URL}/v1/organizations`, {
|
| 68 |
method: 'POST',
|
| 69 |
-
headers: ah(
|
| 70 |
body: JSON.stringify({ name: newOrgName })
|
| 71 |
});
|
| 72 |
if (res.ok) {
|
|
@@ -81,6 +82,7 @@ export default function ClientsManagementView() {
|
|
| 81 |
}
|
| 82 |
};
|
| 83 |
|
|
|
|
| 84 |
const handleEmbeddedSignup = async (orgId: string) => {
|
| 85 |
try {
|
| 86 |
const signupData = await launchEmbeddedSignup();
|
|
@@ -88,7 +90,7 @@ export default function ClientsManagementView() {
|
|
| 88 |
// Send the received credentials to our backend
|
| 89 |
const res = await fetch(`${API_URL}/v1/organizations/${orgId}/whatsapp-setup`, {
|
| 90 |
method: 'POST',
|
| 91 |
-
headers: ah(
|
| 92 |
body: JSON.stringify(signupData)
|
| 93 |
});
|
| 94 |
|
|
@@ -296,7 +298,7 @@ export default function ClientsManagementView() {
|
|
| 296 |
try {
|
| 297 |
const res = await fetch(`${API_URL}/v1/organizations/${selectedOrgForPersonality.id}/personality`, {
|
| 298 |
method: 'PATCH',
|
| 299 |
-
headers: ah(
|
| 300 |
body: JSON.stringify(config)
|
| 301 |
});
|
| 302 |
if (res.ok) {
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
export default function ClientsManagementView() {
|
| 34 |
+
const { token } = useAuth();
|
| 35 |
const [clients, setClients] = useState<Organization[]>([]);
|
| 36 |
const [loading, setLoading] = useState(true);
|
| 37 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
| 39 |
const [newOrgName, setNewOrgName] = useState('');
|
| 40 |
const [isCreating, setIsCreating] = useState(false);
|
| 41 |
const [showGuide, setShowGuide] = useState(false);
|
| 42 |
+
|
| 43 |
const fetchClients = async () => {
|
| 44 |
+
if (!token) return;
|
| 45 |
try {
|
| 46 |
const res = await fetch(`${API_URL}/v1/organizations`, {
|
| 47 |
+
headers: ah(token)
|
| 48 |
});
|
| 49 |
if (res.ok) {
|
| 50 |
const data = await res.json();
|
|
|
|
| 56 |
setLoading(false);
|
| 57 |
}
|
| 58 |
};
|
| 59 |
+
|
| 60 |
useEffect(() => {
|
| 61 |
fetchClients();
|
| 62 |
+
}, [token]);
|
| 63 |
|
| 64 |
const handleCreateOrg = async (e: React.FormEvent) => {
|
| 65 |
e.preventDefault();
|
|
|
|
| 67 |
try {
|
| 68 |
const res = await fetch(`${API_URL}/v1/organizations`, {
|
| 69 |
method: 'POST',
|
| 70 |
+
headers: ah(token!),
|
| 71 |
body: JSON.stringify({ name: newOrgName })
|
| 72 |
});
|
| 73 |
if (res.ok) {
|
|
|
|
| 82 |
}
|
| 83 |
};
|
| 84 |
|
| 85 |
+
|
| 86 |
const handleEmbeddedSignup = async (orgId: string) => {
|
| 87 |
try {
|
| 88 |
const signupData = await launchEmbeddedSignup();
|
|
|
|
| 90 |
// Send the received credentials to our backend
|
| 91 |
const res = await fetch(`${API_URL}/v1/organizations/${orgId}/whatsapp-setup`, {
|
| 92 |
method: 'POST',
|
| 93 |
+
headers: ah(token!),
|
| 94 |
body: JSON.stringify(signupData)
|
| 95 |
});
|
| 96 |
|
|
|
|
| 298 |
try {
|
| 299 |
const res = await fetch(`${API_URL}/v1/organizations/${selectedOrgForPersonality.id}/personality`, {
|
| 300 |
method: 'PATCH',
|
| 301 |
+
headers: ah(token!),
|
| 302 |
body: JSON.stringify(config)
|
| 303 |
});
|
| 304 |
if (res.ok) {
|
apps/admin/src/pages/DashboardPage.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { useTenant } from '../lib/tenant';
|
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function DashboardPage() {
|
| 8 |
-
const {
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [stats, setStats] = useState<any>(null);
|
| 11 |
const [enrollments, setEnrollments] = useState<any[]>([]);
|
|
@@ -20,10 +20,7 @@ export default function DashboardPage() {
|
|
| 20 |
(async () => {
|
| 21 |
setLoading(true);
|
| 22 |
try {
|
| 23 |
-
const h =
|
| 24 |
-
'Authorization': `Bearer ${apiKey}`,
|
| 25 |
-
'x-organization-id': selectedOrgId
|
| 26 |
-
};
|
| 27 |
const [sRes, eRes] = await Promise.all([
|
| 28 |
fetch(`${API_URL}/v1/admin/stats`, { headers: h }),
|
| 29 |
fetch(`${API_URL}/v1/admin/enrollments`, { headers: h })
|
|
@@ -33,7 +30,7 @@ export default function DashboardPage() {
|
|
| 33 |
setEnrollments(await eRes.json());
|
| 34 |
} finally { setLoading(false); }
|
| 35 |
})();
|
| 36 |
-
}, [
|
| 37 |
|
| 38 |
const exportCSV = () => {
|
| 39 |
if (!enrollments.length) return alert('Aucune inscription.');
|
|
|
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function DashboardPage() {
|
| 8 |
+
const { token, logout } = useAuth();
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [stats, setStats] = useState<any>(null);
|
| 11 |
const [enrollments, setEnrollments] = useState<any[]>([]);
|
|
|
|
| 20 |
(async () => {
|
| 21 |
setLoading(true);
|
| 22 |
try {
|
| 23 |
+
const h = ah(token!, selectedOrgId);
|
|
|
|
|
|
|
|
|
|
| 24 |
const [sRes, eRes] = await Promise.all([
|
| 25 |
fetch(`${API_URL}/v1/admin/stats`, { headers: h }),
|
| 26 |
fetch(`${API_URL}/v1/admin/enrollments`, { headers: h })
|
|
|
|
| 30 |
setEnrollments(await eRes.json());
|
| 31 |
} finally { setLoading(false); }
|
| 32 |
})();
|
| 33 |
+
}, [token, logout, selectedOrgId]);
|
| 34 |
|
| 35 |
const exportCSV = () => {
|
| 36 |
if (!enrollments.length) return alert('Aucune inscription.');
|
apps/admin/src/pages/LiveFeed.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import { useState, useEffect, useRef } from 'react';
|
| 2 |
import { Square, Mic, Send, AlertCircle, CheckCircle2, Loader2, User, Briefcase } from 'lucide-react';
|
| 3 |
import { useAuth } from '../lib/auth';
|
|
|
|
|
|
|
| 4 |
|
| 5 |
interface PendingReview {
|
| 6 |
id: string;
|
|
@@ -28,23 +30,16 @@ export default function LiveFeed() {
|
|
| 28 |
const [error, setError] = useState<string | null>(null);
|
| 29 |
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 30 |
|
| 31 |
-
const {
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
// Gestion propre de l'URL de l'API (Support pour Vite, Next.js ou relative)
|
| 36 |
-
const getApiUrl = (endpoint: string) => {
|
| 37 |
-
const rawBaseUrl = import.meta.env?.VITE_API_URL || 'http://localhost:3001';
|
| 38 |
-
const baseUrl = rawBaseUrl.replace(/\/$/, '');
|
| 39 |
-
return `${baseUrl}/v1/admin${endpoint}`;
|
| 40 |
-
};
|
| 41 |
|
| 42 |
const fetchLiveFeed = async () => {
|
|
|
|
| 43 |
try {
|
| 44 |
-
const res = await fetch(
|
| 45 |
-
headers:
|
| 46 |
-
'Authorization': `Bearer ${apiKey}`
|
| 47 |
-
}
|
| 48 |
});
|
| 49 |
if (!res.ok) throw new Error('Erreur réseau');
|
| 50 |
const data = await res.json();
|
|
@@ -58,10 +53,9 @@ export default function LiveFeed() {
|
|
| 58 |
|
| 59 |
useEffect(() => {
|
| 60 |
fetchLiveFeed();
|
| 61 |
-
// Polling every 30 seconds
|
| 62 |
const interval = setInterval(fetchLiveFeed, 30000);
|
| 63 |
return () => clearInterval(interval);
|
| 64 |
-
}, []);
|
| 65 |
|
| 66 |
const handleOverrideSuccess = (userId: string, isSkip: boolean = false) => {
|
| 67 |
setReviews((prev: PendingReview[]) => prev.filter((r: PendingReview) => r.userId !== userId));
|
|
@@ -119,9 +113,9 @@ export default function LiveFeed() {
|
|
| 119 |
review={review}
|
| 120 |
adminId={ADMIN_ID}
|
| 121 |
onSuccess={() => handleOverrideSuccess(review.userId, false)}
|
| 122 |
-
onSkip={() => handleOverrideSuccess(review.userId, true)}
|
| 123 |
-
|
| 124 |
-
|
| 125 |
/>
|
| 126 |
))
|
| 127 |
)}
|
|
@@ -134,7 +128,7 @@ export default function LiveFeed() {
|
|
| 134 |
// MODERATION CARD COMPONENT
|
| 135 |
// ----------------------------------------------------------------------
|
| 136 |
|
| 137 |
-
function ModerationCard({ review, adminId, onSuccess, onSkip,
|
| 138 |
const [transcription, setTranscription] = useState('');
|
| 139 |
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
| 140 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
@@ -142,7 +136,7 @@ function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey
|
|
| 142 |
const [playbackRate, setPlaybackRate] = useState(1);
|
| 143 |
|
| 144 |
const changeSpeed = () => {
|
| 145 |
-
const nextRates = { 1: 1.5, 1.5: 2, 2: 1 }
|
| 146 |
const newRate = nextRates[playbackRate] || 1;
|
| 147 |
setPlaybackRate(newRate);
|
| 148 |
if (audioRef.current) {
|
|
@@ -160,15 +154,12 @@ function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey
|
|
| 160 |
let overrideAudioUrl = "";
|
| 161 |
|
| 162 |
try {
|
| 163 |
-
// 1. Upload the AudioBlob to S3/CloudFlare via an upload route
|
| 164 |
const formData = new FormData();
|
| 165 |
formData.append("file", audioBlob, `override-${review.userId}.webm`);
|
| 166 |
|
| 167 |
-
const uploadRes = await fetch(
|
| 168 |
method: 'POST',
|
| 169 |
-
headers: {
|
| 170 |
-
'Authorization': `Bearer ${apiKey}`
|
| 171 |
-
},
|
| 172 |
body: formData
|
| 173 |
});
|
| 174 |
|
|
@@ -176,13 +167,9 @@ function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey
|
|
| 176 |
const uploadData = await uploadRes.json();
|
| 177 |
overrideAudioUrl = uploadData.url;
|
| 178 |
|
| 179 |
-
|
| 180 |
-
const feedbackRes = await fetch(getApiUrl('/override-feedback'), {
|
| 181 |
method: 'POST',
|
| 182 |
-
headers:
|
| 183 |
-
'Content-Type': 'application/json',
|
| 184 |
-
'Authorization': `Bearer ${apiKey}`
|
| 185 |
-
},
|
| 186 |
body: JSON.stringify({
|
| 187 |
userId: review.userId,
|
| 188 |
trackId: review.trackId,
|
|
@@ -260,7 +247,7 @@ function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey
|
|
| 260 |
className="w-full text-sm p-3 rounded-lg border border-slate-200 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-shadow resize-none h-24"
|
| 261 |
placeholder="Écrivez ce que l'étudiant a dit en le traduisant correctement en français..."
|
| 262 |
value={transcription}
|
| 263 |
-
onChange={(e
|
| 264 |
/>
|
| 265 |
</div>
|
| 266 |
</div>
|
|
@@ -308,7 +295,7 @@ function AudioRecorder({ onRecordComplete }: { onRecordComplete: (blob: Blob | n
|
|
| 308 |
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
| 309 |
|
| 310 |
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 311 |
-
const audioChunks = useRef<
|
| 312 |
|
| 313 |
const startRecording = async () => {
|
| 314 |
try {
|
|
@@ -316,7 +303,7 @@ function AudioRecorder({ onRecordComplete }: { onRecordComplete: (blob: Blob | n
|
|
| 316 |
mediaRecorder.current = new MediaRecorder(stream);
|
| 317 |
audioChunks.current = [];
|
| 318 |
|
| 319 |
-
mediaRecorder.current.ondataavailable = (event:
|
| 320 |
audioChunks.current.push(event.data);
|
| 321 |
};
|
| 322 |
|
|
@@ -340,8 +327,7 @@ function AudioRecorder({ onRecordComplete }: { onRecordComplete: (blob: Blob | n
|
|
| 340 |
if (mediaRecorder.current && isRecording) {
|
| 341 |
mediaRecorder.current.stop();
|
| 342 |
setIsRecording(false);
|
| 343 |
-
|
| 344 |
-
mediaRecorder.current.stream.getTracks().forEach((track: any) => track.stop());
|
| 345 |
}
|
| 346 |
};
|
| 347 |
|
|
|
|
| 1 |
import { useState, useEffect, useRef } from 'react';
|
| 2 |
import { Square, Mic, Send, AlertCircle, CheckCircle2, Loader2, User, Briefcase } from 'lucide-react';
|
| 3 |
import { useAuth } from '../lib/auth';
|
| 4 |
+
import { useTenant } from '../lib/tenant';
|
| 5 |
+
import { API_URL, ah } from '../lib/api';
|
| 6 |
|
| 7 |
interface PendingReview {
|
| 8 |
id: string;
|
|
|
|
| 30 |
const [error, setError] = useState<string | null>(null);
|
| 31 |
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
| 32 |
|
| 33 |
+
const { token, user } = useAuth();
|
| 34 |
+
const { selectedOrgId } = useTenant();
|
| 35 |
+
|
| 36 |
+
const ADMIN_ID = user?.id || "admin_01";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
const fetchLiveFeed = async () => {
|
| 39 |
+
if (!token || !selectedOrgId) return;
|
| 40 |
try {
|
| 41 |
+
const res = await fetch(`${API_URL}/v1/admin/live-feed`, {
|
| 42 |
+
headers: ah(token, selectedOrgId)
|
|
|
|
|
|
|
| 43 |
});
|
| 44 |
if (!res.ok) throw new Error('Erreur réseau');
|
| 45 |
const data = await res.json();
|
|
|
|
| 53 |
|
| 54 |
useEffect(() => {
|
| 55 |
fetchLiveFeed();
|
|
|
|
| 56 |
const interval = setInterval(fetchLiveFeed, 30000);
|
| 57 |
return () => clearInterval(interval);
|
| 58 |
+
}, [token, selectedOrgId]);
|
| 59 |
|
| 60 |
const handleOverrideSuccess = (userId: string, isSkip: boolean = false) => {
|
| 61 |
setReviews((prev: PendingReview[]) => prev.filter((r: PendingReview) => r.userId !== userId));
|
|
|
|
| 113 |
review={review}
|
| 114 |
adminId={ADMIN_ID}
|
| 115 |
onSuccess={() => handleOverrideSuccess(review.userId, false)}
|
| 116 |
+
onSkip={() => handleOverrideSuccess(review.userId, true)}
|
| 117 |
+
token={token!}
|
| 118 |
+
organizationId={selectedOrgId!}
|
| 119 |
/>
|
| 120 |
))
|
| 121 |
)}
|
|
|
|
| 128 |
// MODERATION CARD COMPONENT
|
| 129 |
// ----------------------------------------------------------------------
|
| 130 |
|
| 131 |
+
function ModerationCard({ review, adminId, onSuccess, onSkip, token, organizationId }: { review: PendingReview, adminId: string, onSuccess: () => void, onSkip: () => void, token: string, organizationId: string }) {
|
| 132 |
const [transcription, setTranscription] = useState('');
|
| 133 |
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
| 134 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
| 136 |
const [playbackRate, setPlaybackRate] = useState(1);
|
| 137 |
|
| 138 |
const changeSpeed = () => {
|
| 139 |
+
const nextRates: Record<number, number> = { 1: 1.5, 1.5: 2, 2: 1 };
|
| 140 |
const newRate = nextRates[playbackRate] || 1;
|
| 141 |
setPlaybackRate(newRate);
|
| 142 |
if (audioRef.current) {
|
|
|
|
| 154 |
let overrideAudioUrl = "";
|
| 155 |
|
| 156 |
try {
|
|
|
|
| 157 |
const formData = new FormData();
|
| 158 |
formData.append("file", audioBlob, `override-${review.userId}.webm`);
|
| 159 |
|
| 160 |
+
const uploadRes = await fetch(`${API_URL}/v1/admin/upload`, {
|
| 161 |
method: 'POST',
|
| 162 |
+
headers: { 'Authorization': `Bearer ${token}`, 'x-organization-id': organizationId },
|
|
|
|
|
|
|
| 163 |
body: formData
|
| 164 |
});
|
| 165 |
|
|
|
|
| 167 |
const uploadData = await uploadRes.json();
|
| 168 |
overrideAudioUrl = uploadData.url;
|
| 169 |
|
| 170 |
+
const feedbackRes = await fetch(`${API_URL}/v1/admin/override-feedback`, {
|
|
|
|
| 171 |
method: 'POST',
|
| 172 |
+
headers: ah(token, organizationId),
|
|
|
|
|
|
|
|
|
|
| 173 |
body: JSON.stringify({
|
| 174 |
userId: review.userId,
|
| 175 |
trackId: review.trackId,
|
|
|
|
| 247 |
className="w-full text-sm p-3 rounded-lg border border-slate-200 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-shadow resize-none h-24"
|
| 248 |
placeholder="Écrivez ce que l'étudiant a dit en le traduisant correctement en français..."
|
| 249 |
value={transcription}
|
| 250 |
+
onChange={(e) => setTranscription(e.target.value)}
|
| 251 |
/>
|
| 252 |
</div>
|
| 253 |
</div>
|
|
|
|
| 295 |
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
| 296 |
|
| 297 |
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
| 298 |
+
const audioChunks = useRef<Blob[]>([]);
|
| 299 |
|
| 300 |
const startRecording = async () => {
|
| 301 |
try {
|
|
|
|
| 303 |
mediaRecorder.current = new MediaRecorder(stream);
|
| 304 |
audioChunks.current = [];
|
| 305 |
|
| 306 |
+
mediaRecorder.current.ondataavailable = (event: BlobEvent) => {
|
| 307 |
audioChunks.current.push(event.data);
|
| 308 |
};
|
| 309 |
|
|
|
|
| 327 |
if (mediaRecorder.current && isRecording) {
|
| 328 |
mediaRecorder.current.stop();
|
| 329 |
setIsRecording(false);
|
| 330 |
+
mediaRecorder.current.stream.getTracks().forEach(track => track.stop());
|
|
|
|
| 331 |
}
|
| 332 |
};
|
| 333 |
|
apps/admin/src/pages/LoginPage.tsx
CHANGED
|
@@ -1,32 +1,41 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import { useAuth } from '../lib/auth';
|
|
|
|
| 4 |
import { API_URL } from '../lib/api';
|
| 5 |
|
| 6 |
export default function LoginPage() {
|
| 7 |
-
const { login,
|
|
|
|
| 8 |
const navigate = useNavigate();
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
const [error, setError] = useState('');
|
| 11 |
const [loading, setLoading] = useState(false);
|
| 12 |
|
| 13 |
useEffect(() => {
|
| 14 |
-
if (
|
| 15 |
-
}, [
|
| 16 |
|
| 17 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 18 |
e.preventDefault();
|
| 19 |
setError('');
|
| 20 |
setLoading(true);
|
| 21 |
try {
|
| 22 |
-
const res = await fetch(`${API_URL}/v1/
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
});
|
|
|
|
|
|
|
|
|
|
| 25 |
if (res.ok) {
|
| 26 |
-
login(
|
| 27 |
navigate('/', { replace: true });
|
| 28 |
} else {
|
| 29 |
-
setError(
|
| 30 |
}
|
| 31 |
} catch {
|
| 32 |
setError('Impossible de joindre le serveur.');
|
|
@@ -35,24 +44,72 @@ export default function LoginPage() {
|
|
| 35 |
}
|
| 36 |
};
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
return (
|
| 39 |
-
<div className="min-h-screen
|
| 40 |
-
<div className="bg-white rounded-
|
| 41 |
-
<div className="text-center mb-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
</div>
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
className="
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</button>
|
| 55 |
</form>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
);
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
import { useAuth } from '../lib/auth';
|
| 4 |
+
import { useTenant } from '../lib/tenant';
|
| 5 |
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function LoginPage() {
|
| 8 |
+
const { login, token } = useAuth();
|
| 9 |
+
const { currentOrg, isSubdomain, slug } = useTenant();
|
| 10 |
const navigate = useNavigate();
|
| 11 |
+
|
| 12 |
+
const [email, setEmail] = useState('');
|
| 13 |
+
const [password, setPassword] = useState('');
|
| 14 |
const [error, setError] = useState('');
|
| 15 |
const [loading, setLoading] = useState(false);
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
+
if (token) navigate('/', { replace: true });
|
| 19 |
+
}, [token, navigate]);
|
| 20 |
|
| 21 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 22 |
e.preventDefault();
|
| 23 |
setError('');
|
| 24 |
setLoading(true);
|
| 25 |
try {
|
| 26 |
+
const res = await fetch(`${API_URL}/v1/auth/login`, {
|
| 27 |
+
method: 'POST',
|
| 28 |
+
headers: { 'Content-Type': 'application/json' },
|
| 29 |
+
body: JSON.stringify({ email, password })
|
| 30 |
});
|
| 31 |
+
|
| 32 |
+
const data = await res.json();
|
| 33 |
+
|
| 34 |
if (res.ok) {
|
| 35 |
+
login(data.token, data.user);
|
| 36 |
navigate('/', { replace: true });
|
| 37 |
} else {
|
| 38 |
+
setError(data.message || 'Identifiants invalides.');
|
| 39 |
}
|
| 40 |
} catch {
|
| 41 |
setError('Impossible de joindre le serveur.');
|
|
|
|
| 44 |
}
|
| 45 |
};
|
| 46 |
|
| 47 |
+
const brandingColor = currentOrg?.brandingData?.primaryColor || '#0f172a';
|
| 48 |
+
const logoUrl = currentOrg?.brandingData?.logoUrl;
|
| 49 |
+
|
| 50 |
return (
|
| 51 |
+
<div className="min-h-screen flex items-center justify-center p-4 bg-slate-50">
|
| 52 |
+
<div className="bg-white rounded-3xl shadow-xl p-8 w-full max-w-md border border-slate-100">
|
| 53 |
+
<div className="text-center mb-8">
|
| 54 |
+
{logoUrl ? (
|
| 55 |
+
<img src={logoUrl} alt="Logo" className="h-12 mx-auto mb-4 object-contain" />
|
| 56 |
+
) : (
|
| 57 |
+
<div className="text-4xl mb-4">🎓</div>
|
| 58 |
+
)}
|
| 59 |
+
<h1 className="text-2xl font-bold text-slate-900">
|
| 60 |
+
{currentOrg?.name || 'Xamlé Studio'}
|
| 61 |
+
</h1>
|
| 62 |
+
<p className="text-sm text-slate-500 mt-2">
|
| 63 |
+
{isSubdomain ? `Espace administrateur ${currentOrg?.name || slug}` : 'Connectez-vous à votre espace EdTech'}
|
| 64 |
+
</p>
|
| 65 |
</div>
|
| 66 |
+
|
| 67 |
+
<form onSubmit={handleSubmit} className="space-y-5">
|
| 68 |
+
<div>
|
| 69 |
+
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Email</label>
|
| 70 |
+
<input
|
| 71 |
+
type="email"
|
| 72 |
+
required
|
| 73 |
+
placeholder="admin@ecole.com"
|
| 74 |
+
value={email}
|
| 75 |
+
onChange={e => setEmail(e.target.value)}
|
| 76 |
+
className="w-full bg-slate-50 border-none rounded-2xl px-5 py-4 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div>
|
| 81 |
+
<label className="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-2">Mot de passe</label>
|
| 82 |
+
<input
|
| 83 |
+
type="password"
|
| 84 |
+
required
|
| 85 |
+
placeholder="••••••••"
|
| 86 |
+
value={password}
|
| 87 |
+
onChange={e => setPassword(e.target.value)}
|
| 88 |
+
className="w-full bg-slate-50 border-none rounded-2xl px-5 py-4 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition"
|
| 89 |
+
/>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{error && (
|
| 93 |
+
<div className="p-4 bg-red-50 text-red-600 rounded-2xl text-xs font-medium border border-red-100">
|
| 94 |
+
⚠️ {error}
|
| 95 |
+
</div>
|
| 96 |
+
)}
|
| 97 |
+
|
| 98 |
+
<button
|
| 99 |
+
type="submit"
|
| 100 |
+
disabled={loading}
|
| 101 |
+
style={{ backgroundColor: brandingColor }}
|
| 102 |
+
className="w-full text-white py-4 rounded-2xl font-bold text-sm shadow-lg shadow-indigo-200 transition active:scale-[0.98] disabled:opacity-50"
|
| 103 |
+
>
|
| 104 |
+
{loading ? 'Connexion...' : 'Se connecter'}
|
| 105 |
</button>
|
| 106 |
</form>
|
| 107 |
+
|
| 108 |
+
{!isSubdomain && (
|
| 109 |
+
<p className="text-center text-[10px] text-slate-400 mt-8 uppercase tracking-widest font-bold">
|
| 110 |
+
Propulsé par Xamlé Studio
|
| 111 |
+
</p>
|
| 112 |
+
)}
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
);
|
apps/admin/src/pages/TrackDaysPage.tsx
CHANGED
|
@@ -3,10 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|
| 3 |
import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
-
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackDaysPage() {
|
| 9 |
-
const {
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const { trackId } = useParams<{ trackId: string }>();
|
| 12 |
const navigate = useNavigate();
|
|
@@ -16,30 +16,26 @@ export default function TrackDaysPage() {
|
|
| 16 |
const [editing, setEditing] = useState<any>(null);
|
| 17 |
const [saving, setSaving] = useState(false);
|
| 18 |
|
| 19 |
-
const ah = (k: string) => ({
|
| 20 |
-
'Authorization': `Bearer ${k}`,
|
| 21 |
-
'Content-Type': 'application/json',
|
| 22 |
-
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 23 |
-
});
|
| 24 |
-
|
| 25 |
const load = async () => {
|
|
|
|
| 26 |
const [tR, dR] = await Promise.all([
|
| 27 |
-
fetch(`${API_URL}/v1/admin/tracks/${trackId}`, { headers: ah(
|
| 28 |
-
fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`, { headers: ah(
|
| 29 |
]);
|
| 30 |
setTrack(await tR.json());
|
| 31 |
setDays(await dR.json());
|
| 32 |
};
|
| 33 |
|
| 34 |
-
useEffect(() => { load(); }, []);
|
| 35 |
|
| 36 |
const emptyDay = { dayNumber: (days.length || 0) + 1, title: '', lessonText: '', audioUrl: '', exerciseType: 'TEXT', exercisePrompt: '', validationKeyword: '' };
|
| 37 |
|
| 38 |
const saveDay = async (e: React.FormEvent) => {
|
| 39 |
e.preventDefault();
|
|
|
|
| 40 |
setSaving(true);
|
| 41 |
const url = editing.id ? `${API_URL}/v1/admin/tracks/${trackId}/days/${editing.id}` : `${API_URL}/v1/admin/tracks/${trackId}/days`;
|
| 42 |
-
await fetch(url, { method: editing.id ? 'PUT' : 'POST', headers: ah(
|
| 43 |
setEditing(null);
|
| 44 |
load();
|
| 45 |
setSaving(false);
|
|
@@ -47,7 +43,7 @@ export default function TrackDaysPage() {
|
|
| 47 |
|
| 48 |
const del = async (dayId: string) => {
|
| 49 |
if (!confirm('Supprimer ce jour?')) return;
|
| 50 |
-
await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(
|
| 51 |
load();
|
| 52 |
};
|
| 53 |
|
|
|
|
| 3 |
import { Plus, Edit2, Trash2, ArrowLeft, X, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
+
import { API_URL, ah } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackDaysPage() {
|
| 9 |
+
const { token } = useAuth();
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const { trackId } = useParams<{ trackId: string }>();
|
| 12 |
const navigate = useNavigate();
|
|
|
|
| 16 |
const [editing, setEditing] = useState<any>(null);
|
| 17 |
const [saving, setSaving] = useState(false);
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const load = async () => {
|
| 20 |
+
if (!token || !selectedOrgId) return;
|
| 21 |
const [tR, dR] = await Promise.all([
|
| 22 |
+
fetch(`${API_URL}/v1/admin/tracks/${trackId}`, { headers: ah(token, selectedOrgId) }),
|
| 23 |
+
fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`, { headers: ah(token, selectedOrgId) })
|
| 24 |
]);
|
| 25 |
setTrack(await tR.json());
|
| 26 |
setDays(await dR.json());
|
| 27 |
};
|
| 28 |
|
| 29 |
+
useEffect(() => { load(); }, [token, selectedOrgId]);
|
| 30 |
|
| 31 |
const emptyDay = { dayNumber: (days.length || 0) + 1, title: '', lessonText: '', audioUrl: '', exerciseType: 'TEXT', exercisePrompt: '', validationKeyword: '' };
|
| 32 |
|
| 33 |
const saveDay = async (e: React.FormEvent) => {
|
| 34 |
e.preventDefault();
|
| 35 |
+
if (!token || !selectedOrgId) return;
|
| 36 |
setSaving(true);
|
| 37 |
const url = editing.id ? `${API_URL}/v1/admin/tracks/${trackId}/days/${editing.id}` : `${API_URL}/v1/admin/tracks/${trackId}/days`;
|
| 38 |
+
await fetch(url, { method: editing.id ? 'PUT' : 'POST', headers: ah(token, selectedOrgId), body: JSON.stringify(editing) });
|
| 39 |
setEditing(null);
|
| 40 |
load();
|
| 41 |
setSaving(false);
|
|
|
|
| 43 |
|
| 44 |
const del = async (dayId: string) => {
|
| 45 |
if (!confirm('Supprimer ce jour?')) return;
|
| 46 |
+
await fetch(`${API_URL}/v1/admin/tracks/${trackId}/days/${dayId}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
|
| 47 |
load();
|
| 48 |
};
|
| 49 |
|
apps/admin/src/pages/TrackFormPage.tsx
CHANGED
|
@@ -3,10 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|
| 3 |
import { ArrowLeft, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
-
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackFormPage() {
|
| 9 |
-
const {
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const { id } = useParams<{ id: string }>();
|
| 12 |
const navigate = useNavigate();
|
|
@@ -17,15 +17,10 @@ export default function TrackFormPage() {
|
|
| 17 |
isPremium: false, priceAmount: 0, stripePriceId: ''
|
| 18 |
});
|
| 19 |
const [saving, setSaving] = useState(false);
|
| 20 |
-
const ah = (k: string) => ({
|
| 21 |
-
'Authorization': `Bearer ${k}`,
|
| 22 |
-
'Content-Type': 'application/json',
|
| 23 |
-
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 24 |
-
});
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
-
if (!isNew) {
|
| 28 |
-
fetch(`${API_URL}/v1/admin/tracks/${id}`, { headers: ah(
|
| 29 |
.then(r => r.json())
|
| 30 |
.then(t => setForm({
|
| 31 |
title: t.title, description: t.description || '', duration: t.duration,
|
|
@@ -33,17 +28,18 @@ export default function TrackFormPage() {
|
|
| 33 |
stripePriceId: t.stripePriceId || ''
|
| 34 |
}));
|
| 35 |
}
|
| 36 |
-
}, [id,
|
| 37 |
|
| 38 |
const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
|
| 39 |
|
| 40 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 41 |
e.preventDefault();
|
|
|
|
| 42 |
setSaving(true);
|
| 43 |
const url = isNew ? `${API_URL}/v1/admin/tracks` : `${API_URL}/v1/admin/tracks/${id}`;
|
| 44 |
await fetch(url, {
|
| 45 |
method: isNew ? 'POST' : 'PUT',
|
| 46 |
-
headers: ah(
|
| 47 |
body: JSON.stringify({
|
| 48 |
...form,
|
| 49 |
priceAmount: form.priceAmount || undefined,
|
|
|
|
| 3 |
import { ArrowLeft, Save } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
+
import { API_URL, ah } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackFormPage() {
|
| 9 |
+
const { token } = useAuth();
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const { id } = useParams<{ id: string }>();
|
| 12 |
const navigate = useNavigate();
|
|
|
|
| 17 |
isPremium: false, priceAmount: 0, stripePriceId: ''
|
| 18 |
});
|
| 19 |
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
+
if (!isNew && token && selectedOrgId) {
|
| 23 |
+
fetch(`${API_URL}/v1/admin/tracks/${id}`, { headers: ah(token, selectedOrgId) })
|
| 24 |
.then(r => r.json())
|
| 25 |
.then(t => setForm({
|
| 26 |
title: t.title, description: t.description || '', duration: t.duration,
|
|
|
|
| 28 |
stripePriceId: t.stripePriceId || ''
|
| 29 |
}));
|
| 30 |
}
|
| 31 |
+
}, [id, token, selectedOrgId, isNew]);
|
| 32 |
|
| 33 |
const inp = "w-full border border-slate-200 rounded-xl px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-slate-300";
|
| 34 |
|
| 35 |
const handleSubmit = async (e: React.FormEvent) => {
|
| 36 |
e.preventDefault();
|
| 37 |
+
if (!token || !selectedOrgId) return;
|
| 38 |
setSaving(true);
|
| 39 |
const url = isNew ? `${API_URL}/v1/admin/tracks` : `${API_URL}/v1/admin/tracks/${id}`;
|
| 40 |
await fetch(url, {
|
| 41 |
method: isNew ? 'POST' : 'PUT',
|
| 42 |
+
headers: ah(token, selectedOrgId),
|
| 43 |
body: JSON.stringify({
|
| 44 |
...form,
|
| 45 |
priceAmount: form.priceAmount || undefined,
|
apps/admin/src/pages/TrackListPage.tsx
CHANGED
|
@@ -3,36 +3,31 @@ import { useNavigate } from 'react-router-dom';
|
|
| 3 |
import { BookOpen, Plus, Edit2, Trash2, ChevronRight, Building2 } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
-
import { API_URL } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackListPage() {
|
| 9 |
-
const {
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const navigate = useNavigate();
|
| 12 |
const [tracks, setTracks] = useState<any[]>([]);
|
| 13 |
const [loading, setLoading] = useState(true);
|
| 14 |
|
| 15 |
-
const ah = (k: string) => ({
|
| 16 |
-
'Authorization': `Bearer ${k}`,
|
| 17 |
-
'Content-Type': 'application/json',
|
| 18 |
-
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 19 |
-
});
|
| 20 |
-
|
| 21 |
const load = async () => {
|
| 22 |
-
|
|
|
|
| 23 |
setTracks(await r.json());
|
| 24 |
setLoading(false);
|
| 25 |
};
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
-
if (selectedOrgId
|
| 29 |
load();
|
| 30 |
}
|
| 31 |
-
}, [selectedOrgId,
|
| 32 |
|
| 33 |
const del = async (id: string) => {
|
| 34 |
if (!confirm('Supprimer ce parcours ?')) return;
|
| 35 |
-
await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(
|
| 36 |
load();
|
| 37 |
};
|
| 38 |
|
|
|
|
| 3 |
import { BookOpen, Plus, Edit2, Trash2, ChevronRight, Building2 } from 'lucide-react';
|
| 4 |
import { useAuth } from '../lib/auth';
|
| 5 |
import { useTenant } from '../lib/tenant';
|
| 6 |
+
import { API_URL, ah } from '../lib/api';
|
| 7 |
|
| 8 |
export default function TrackListPage() {
|
| 9 |
+
const { token } = useAuth();
|
| 10 |
const { selectedOrgId } = useTenant();
|
| 11 |
const navigate = useNavigate();
|
| 12 |
const [tracks, setTracks] = useState<any[]>([]);
|
| 13 |
const [loading, setLoading] = useState(true);
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const load = async () => {
|
| 16 |
+
if (!token || !selectedOrgId) return;
|
| 17 |
+
const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(token, selectedOrgId) });
|
| 18 |
setTracks(await r.json());
|
| 19 |
setLoading(false);
|
| 20 |
};
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
+
if (selectedOrgId && token) {
|
| 24 |
load();
|
| 25 |
}
|
| 26 |
+
}, [selectedOrgId, token]);
|
| 27 |
|
| 28 |
const del = async (id: string) => {
|
| 29 |
if (!confirm('Supprimer ce parcours ?')) return;
|
| 30 |
+
await fetch(`${API_URL}/v1/admin/tracks/${id}`, { method: 'DELETE', headers: ah(token!, selectedOrgId!) });
|
| 31 |
load();
|
| 32 |
};
|
| 33 |
|
apps/admin/src/pages/TrainingLab.tsx
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { useAuth } from '../lib/auth';
|
|
|
|
|
|
|
| 3 |
import { Upload, Database, Save, Activity, CheckCircle, AlertTriangle, RefreshCw, Lightbulb } from 'lucide-react';
|
| 4 |
|
| 5 |
-
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
| 6 |
-
|
| 7 |
interface TrainingData {
|
| 8 |
id: string;
|
| 9 |
audioUrl: string;
|
|
@@ -16,7 +16,8 @@ interface TrainingData {
|
|
| 16 |
}
|
| 17 |
|
| 18 |
export default function TrainingLab() {
|
| 19 |
-
const {
|
|
|
|
| 20 |
const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
|
| 21 |
const [audios, setAudios] = useState<TrainingData[]>([]);
|
| 22 |
const [selectedAudio, setSelectedAudio] = useState<TrainingData | null>(null);
|
|
@@ -31,10 +32,11 @@ export default function TrainingLab() {
|
|
| 31 |
const [recalcResult, setRecalcResult] = useState<{ processed: number, avgRawWER: number, avgNormalizedWER: number, improvementPercent: number } | null>(null);
|
| 32 |
|
| 33 |
const fetchAudios = async () => {
|
|
|
|
| 34 |
setLoading(true);
|
| 35 |
try {
|
| 36 |
const res = await fetch(`${API_URL}/v1/admin/training/audios`, {
|
| 37 |
-
headers:
|
| 38 |
});
|
| 39 |
if (res.status === 401) return logout();
|
| 40 |
const data = await res.json();
|
|
@@ -50,7 +52,7 @@ export default function TrainingLab() {
|
|
| 50 |
if (mode === 'db') {
|
| 51 |
fetchAudios();
|
| 52 |
}
|
| 53 |
-
}, [mode,
|
| 54 |
|
| 55 |
const handleSubmit = async () => {
|
| 56 |
if (!selectedAudio || !manualCorrection.trim()) return;
|
|
@@ -59,10 +61,7 @@ export default function TrainingLab() {
|
|
| 59 |
try {
|
| 60 |
const res = await fetch(`${API_URL}/v1/admin/training/submit`, {
|
| 61 |
method: 'POST',
|
| 62 |
-
headers:
|
| 63 |
-
'Authorization': `Bearer ${apiKey}`,
|
| 64 |
-
'Content-Type': 'application/json'
|
| 65 |
-
},
|
| 66 |
body: JSON.stringify({
|
| 67 |
id: selectedAudio.id,
|
| 68 |
audioUrl: selectedAudio.audioUrl,
|
|
@@ -95,7 +94,7 @@ export default function TrainingLab() {
|
|
| 95 |
setLoading(true);
|
| 96 |
try {
|
| 97 |
const res = await fetch(`${API_URL}/v1/admin/training/suggestions`, {
|
| 98 |
-
headers:
|
| 99 |
});
|
| 100 |
if (res.status === 401) return logout();
|
| 101 |
const data = await res.json();
|
|
@@ -115,7 +114,7 @@ export default function TrainingLab() {
|
|
| 115 |
try {
|
| 116 |
const res = await fetch(`${API_URL}/v1/admin/training/apply-suggestions`, {
|
| 117 |
method: 'POST',
|
| 118 |
-
headers:
|
| 119 |
body: JSON.stringify({ suggestions: payload })
|
| 120 |
});
|
| 121 |
if (res.status === 401) return logout();
|
|
@@ -134,7 +133,7 @@ export default function TrainingLab() {
|
|
| 134 |
try {
|
| 135 |
const res = await fetch(`${API_URL}/v1/admin/training/recalculate-wer`, {
|
| 136 |
method: 'POST',
|
| 137 |
-
headers:
|
| 138 |
});
|
| 139 |
if (res.status === 401) return logout();
|
| 140 |
const json = await res.json();
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { useAuth } from '../lib/auth';
|
| 3 |
+
import { useTenant } from '../lib/tenant';
|
| 4 |
+
import { API_URL, ah } from '../lib/api';
|
| 5 |
import { Upload, Database, Save, Activity, CheckCircle, AlertTriangle, RefreshCw, Lightbulb } from 'lucide-react';
|
| 6 |
|
|
|
|
|
|
|
| 7 |
interface TrainingData {
|
| 8 |
id: string;
|
| 9 |
audioUrl: string;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
export default function TrainingLab() {
|
| 19 |
+
const { token, logout } = useAuth();
|
| 20 |
+
const { selectedOrgId } = useTenant();
|
| 21 |
const [mode, setMode] = useState<'db' | 'upload' | 'suggestions'>('db');
|
| 22 |
const [audios, setAudios] = useState<TrainingData[]>([]);
|
| 23 |
const [selectedAudio, setSelectedAudio] = useState<TrainingData | null>(null);
|
|
|
|
| 32 |
const [recalcResult, setRecalcResult] = useState<{ processed: number, avgRawWER: number, avgNormalizedWER: number, improvementPercent: number } | null>(null);
|
| 33 |
|
| 34 |
const fetchAudios = async () => {
|
| 35 |
+
if (!token || !selectedOrgId) return;
|
| 36 |
setLoading(true);
|
| 37 |
try {
|
| 38 |
const res = await fetch(`${API_URL}/v1/admin/training/audios`, {
|
| 39 |
+
headers: ah(token, selectedOrgId)
|
| 40 |
});
|
| 41 |
if (res.status === 401) return logout();
|
| 42 |
const data = await res.json();
|
|
|
|
| 52 |
if (mode === 'db') {
|
| 53 |
fetchAudios();
|
| 54 |
}
|
| 55 |
+
}, [mode, token, selectedOrgId, logout]);
|
| 56 |
|
| 57 |
const handleSubmit = async () => {
|
| 58 |
if (!selectedAudio || !manualCorrection.trim()) return;
|
|
|
|
| 61 |
try {
|
| 62 |
const res = await fetch(`${API_URL}/v1/admin/training/submit`, {
|
| 63 |
method: 'POST',
|
| 64 |
+
headers: ah(token!, selectedOrgId!),
|
|
|
|
|
|
|
|
|
|
| 65 |
body: JSON.stringify({
|
| 66 |
id: selectedAudio.id,
|
| 67 |
audioUrl: selectedAudio.audioUrl,
|
|
|
|
| 94 |
setLoading(true);
|
| 95 |
try {
|
| 96 |
const res = await fetch(`${API_URL}/v1/admin/training/suggestions`, {
|
| 97 |
+
headers: ah(token!, selectedOrgId!)
|
| 98 |
});
|
| 99 |
if (res.status === 401) return logout();
|
| 100 |
const data = await res.json();
|
|
|
|
| 114 |
try {
|
| 115 |
const res = await fetch(`${API_URL}/v1/admin/training/apply-suggestions`, {
|
| 116 |
method: 'POST',
|
| 117 |
+
headers: ah(token!, selectedOrgId!),
|
| 118 |
body: JSON.stringify({ suggestions: payload })
|
| 119 |
});
|
| 120 |
if (res.status === 401) return logout();
|
|
|
|
| 133 |
try {
|
| 134 |
const res = await fetch(`${API_URL}/v1/admin/training/recalculate-wer`, {
|
| 135 |
method: 'POST',
|
| 136 |
+
headers: ah(token!, selectedOrgId!)
|
| 137 |
});
|
| 138 |
if (res.status === 401) return logout();
|
| 139 |
const json = await res.json();
|
apps/admin/src/pages/UserListPage.tsx
CHANGED
|
@@ -2,10 +2,10 @@ import { useEffect, useState } from 'react';
|
|
| 2 |
import { X, Building2, Loader2 } from 'lucide-react';
|
| 3 |
import { useAuth } from '../lib/auth';
|
| 4 |
import { useTenant } from '../lib/tenant';
|
| 5 |
-
import { API_URL } from '../lib/api';
|
| 6 |
|
| 7 |
export default function UserListPage() {
|
| 8 |
-
const {
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [users, setUsers] = useState<any[]>([]);
|
| 11 |
const [total, setTotal] = useState(0);
|
|
@@ -14,11 +14,7 @@ export default function UserListPage() {
|
|
| 14 |
const [messages, setMessages] = useState<any[]>([]);
|
| 15 |
const [loadingMsg, setLoadingMsg] = useState(false);
|
| 16 |
|
| 17 |
-
|
| 18 |
-
'Authorization': `Bearer ${k}`,
|
| 19 |
-
'Content-Type': 'application/json',
|
| 20 |
-
...(selectedOrgId ? { 'x-organization-id': selectedOrgId } : {})
|
| 21 |
-
});
|
| 22 |
|
| 23 |
useEffect(() => {
|
| 24 |
if (!selectedOrgId) {
|
|
@@ -26,20 +22,20 @@ export default function UserListPage() {
|
|
| 26 |
return;
|
| 27 |
}
|
| 28 |
setLoading(true);
|
| 29 |
-
fetch(`${API_URL}/v1/admin/users`, { headers: ah(
|
| 30 |
.then(r => r.json())
|
| 31 |
.then(d => {
|
| 32 |
setUsers(d.users || d);
|
| 33 |
setTotal(d.total || 0);
|
| 34 |
setLoading(false);
|
| 35 |
});
|
| 36 |
-
}, [
|
| 37 |
|
| 38 |
const viewMessages = async (userId: string) => {
|
| 39 |
setLoadingMsg(true);
|
| 40 |
setSelectedUser({ id: userId });
|
| 41 |
try {
|
| 42 |
-
const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(
|
| 43 |
const data = await res.json();
|
| 44 |
setSelectedUser(data.user);
|
| 45 |
setMessages(data.messages || []);
|
|
|
|
| 2 |
import { X, Building2, Loader2 } from 'lucide-react';
|
| 3 |
import { useAuth } from '../lib/auth';
|
| 4 |
import { useTenant } from '../lib/tenant';
|
| 5 |
+
import { API_URL, ah } from '../lib/api';
|
| 6 |
|
| 7 |
export default function UserListPage() {
|
| 8 |
+
const { token } = useAuth();
|
| 9 |
const { selectedOrgId } = useTenant();
|
| 10 |
const [users, setUsers] = useState<any[]>([]);
|
| 11 |
const [total, setTotal] = useState(0);
|
|
|
|
| 14 |
const [messages, setMessages] = useState<any[]>([]);
|
| 15 |
const [loadingMsg, setLoadingMsg] = useState(false);
|
| 16 |
|
| 17 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
if (!selectedOrgId) {
|
|
|
|
| 22 |
return;
|
| 23 |
}
|
| 24 |
setLoading(true);
|
| 25 |
+
fetch(`${API_URL}/v1/admin/users`, { headers: ah(token!, selectedOrgId) })
|
| 26 |
.then(r => r.json())
|
| 27 |
.then(d => {
|
| 28 |
setUsers(d.users || d);
|
| 29 |
setTotal(d.total || 0);
|
| 30 |
setLoading(false);
|
| 31 |
});
|
| 32 |
+
}, [token, selectedOrgId]);
|
| 33 |
|
| 34 |
const viewMessages = async (userId: string) => {
|
| 35 |
setLoadingMsg(true);
|
| 36 |
setSelectedUser({ id: userId });
|
| 37 |
try {
|
| 38 |
+
const res = await fetch(`${API_URL}/v1/admin/users/${userId}/messages`, { headers: ah(token!, selectedOrgId) });
|
| 39 |
const data = await res.json();
|
| 40 |
setSelectedUser(data.user);
|
| 41 |
setMessages(data.messages || []);
|
apps/api/package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
| 15 |
"@bull-board/api": "^7.0.0",
|
| 16 |
"@bull-board/fastify": "^7.0.0",
|
| 17 |
"@fastify/cors": "^8.0.0",
|
|
|
|
| 18 |
"@fastify/multipart": "^10.0.0",
|
| 19 |
"@fastify/rate-limit": "^9.1.0",
|
| 20 |
"@fastify/static": "^9.1.3",
|
|
@@ -23,7 +24,9 @@
|
|
| 23 |
"@repo/database": "workspace:*",
|
| 24 |
"@repo/prompts": "workspace:*",
|
| 25 |
"@repo/shared-types": "workspace:*",
|
|
|
|
| 26 |
"axios": "^1.13.5",
|
|
|
|
| 27 |
"bullmq": "^5.1.0",
|
| 28 |
"diff": "^8.0.3",
|
| 29 |
"dotenv": "^16.6.1",
|
|
|
|
| 15 |
"@bull-board/api": "^7.0.0",
|
| 16 |
"@bull-board/fastify": "^7.0.0",
|
| 17 |
"@fastify/cors": "^8.0.0",
|
| 18 |
+
"@fastify/jwt": "^10.0.0",
|
| 19 |
"@fastify/multipart": "^10.0.0",
|
| 20 |
"@fastify/rate-limit": "^9.1.0",
|
| 21 |
"@fastify/static": "^9.1.3",
|
|
|
|
| 24 |
"@repo/database": "workspace:*",
|
| 25 |
"@repo/prompts": "workspace:*",
|
| 26 |
"@repo/shared-types": "workspace:*",
|
| 27 |
+
"@types/bcrypt": "^6.0.0",
|
| 28 |
"axios": "^1.13.5",
|
| 29 |
+
"bcrypt": "^6.0.0",
|
| 30 |
"bullmq": "^5.1.0",
|
| 31 |
"diff": "^8.0.3",
|
| 32 |
"dotenv": "^16.6.1",
|
apps/api/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import Fastify from 'fastify';
|
|
| 6 |
import fastifyStatic from '@fastify/static';
|
| 7 |
import fastifyMultipart from '@fastify/multipart';
|
| 8 |
import fastifyRateLimit from '@fastify/rate-limit';
|
|
|
|
| 9 |
import cors from '@fastify/cors';
|
| 10 |
import { whatsappRoutes } from './routes/whatsapp';
|
| 11 |
import { adminRoutes } from './routes/admin';
|
|
@@ -24,6 +25,11 @@ declare module 'fastify' {
|
|
| 24 |
}
|
| 25 |
interface FastifyRequest {
|
| 26 |
rawBody?: Buffer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
interface FastifyContextConfig {
|
| 29 |
requireAuth?: boolean;
|
|
@@ -34,7 +40,7 @@ declare module 'fastify' {
|
|
| 34 |
// ── Fail-fast: check critical secrets on startup ───────────────────────────────
|
| 35 |
// Only WHATSAPP_VERIFY_TOKEN is strictly needed at startup (for Meta webhook validation).
|
| 36 |
// All other secrets are validated lazily (guarded routes return 503 if missing).
|
| 37 |
-
const REQUIRED_ENV = ['WHATSAPP_VERIFY_TOKEN'];
|
| 38 |
const WARN_ENV = ['ADMIN_API_KEY', 'WHATSAPP_APP_SECRET'];
|
| 39 |
|
| 40 |
for (const key of REQUIRED_ENV) {
|
|
@@ -60,6 +66,11 @@ const server = Fastify({
|
|
| 60 |
// ── CORS ───────────────────────────────────────────────────────────────────────
|
| 61 |
server.register(cors);
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
// ── Static & Multipart ────────────────────────────────────────────────────────
|
| 64 |
server.register(fastifyStatic, {
|
| 65 |
root: '/tmp',
|
|
@@ -89,48 +100,69 @@ async function setupRateLimit() {
|
|
| 89 |
logger.info('[RATE-LIMIT] Global rate limiting enabled (100 req / 15 min)');
|
| 90 |
}
|
| 91 |
|
|
|
|
|
|
|
| 92 |
// ── Public Routes (no auth) ────────────────────────────────────────────────────
|
|
|
|
| 93 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
| 94 |
server.register(studentRoutes, { prefix: '/v1/student' });
|
| 95 |
|
| 96 |
// ── Stripe Webhook (public — Stripe can't send API Key, verified by signature) ─
|
| 97 |
server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
|
| 98 |
|
| 99 |
-
// ── Private Routes (
|
| 100 |
server.register(async function guardedRoutes(scope) {
|
| 101 |
scope.addHook('onRequest', async (request, reply) => {
|
| 102 |
-
// 🚨 CORS FIX: Skip authentication for preflight OPTIONS requests
|
| 103 |
if (request.method === 'OPTIONS') return;
|
| 104 |
|
| 105 |
const apiKey = process.env.ADMIN_API_KEY;
|
| 106 |
-
|
| 107 |
-
if (!apiKey) {
|
| 108 |
-
request.log.error('ADMIN_API_KEY is not configured!');
|
| 109 |
-
return reply.code(503).send({ error: 'Service misconfigured' });
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
const authHeader = request.headers['authorization'];
|
|
|
|
| 113 |
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 114 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Missing Authorization header' });
|
| 115 |
}
|
| 116 |
|
| 117 |
const token = authHeader.slice(7);
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
-
// 🏢 Multi-Tenant
|
| 123 |
-
const
|
| 124 |
|
| 125 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
const isOrgIndependentRoute = request.url.startsWith('/v1/organizations') || request.url.startsWith('/v1/internal');
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
return reply.code(400).send({ error: 'Bad Request', message: 'Missing x-organization-id header' });
|
| 130 |
}
|
| 131 |
|
| 132 |
-
if (
|
| 133 |
-
request.log.info(`[CONTEXT] Setting Organization Context: ${
|
| 134 |
}
|
| 135 |
});
|
| 136 |
|
|
|
|
| 6 |
import fastifyStatic from '@fastify/static';
|
| 7 |
import fastifyMultipart from '@fastify/multipart';
|
| 8 |
import fastifyRateLimit from '@fastify/rate-limit';
|
| 9 |
+
import fastifyJwt from '@fastify/jwt';
|
| 10 |
import cors from '@fastify/cors';
|
| 11 |
import { whatsappRoutes } from './routes/whatsapp';
|
| 12 |
import { adminRoutes } from './routes/admin';
|
|
|
|
| 25 |
}
|
| 26 |
interface FastifyRequest {
|
| 27 |
rawBody?: Buffer;
|
| 28 |
+
user: {
|
| 29 |
+
id: string;
|
| 30 |
+
organizationId: string;
|
| 31 |
+
role: 'STUDENT' | 'ORG_MEMBER' | 'ORG_ADMIN' | 'SUPER_ADMIN' | 'ADMIN';
|
| 32 |
+
};
|
| 33 |
}
|
| 34 |
interface FastifyContextConfig {
|
| 35 |
requireAuth?: boolean;
|
|
|
|
| 40 |
// ── Fail-fast: check critical secrets on startup ───────────────────────────────
|
| 41 |
// Only WHATSAPP_VERIFY_TOKEN is strictly needed at startup (for Meta webhook validation).
|
| 42 |
// All other secrets are validated lazily (guarded routes return 503 if missing).
|
| 43 |
+
const REQUIRED_ENV = ['WHATSAPP_VERIFY_TOKEN', 'JWT_SECRET'];
|
| 44 |
const WARN_ENV = ['ADMIN_API_KEY', 'WHATSAPP_APP_SECRET'];
|
| 45 |
|
| 46 |
for (const key of REQUIRED_ENV) {
|
|
|
|
| 66 |
// ── CORS ───────────────────────────────────────────────────────────────────────
|
| 67 |
server.register(cors);
|
| 68 |
|
| 69 |
+
// ── JWT ────────────────────────────────────────────────────────────────────────
|
| 70 |
+
server.register(fastifyJwt, {
|
| 71 |
+
secret: process.env.JWT_SECRET || 'dev-secret-keep-it-secret-keep-it-safe'
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
// ── Static & Multipart ────────────────────────────────────────────────────────
|
| 75 |
server.register(fastifyStatic, {
|
| 76 |
root: '/tmp',
|
|
|
|
| 100 |
logger.info('[RATE-LIMIT] Global rate limiting enabled (100 req / 15 min)');
|
| 101 |
}
|
| 102 |
|
| 103 |
+
import { authRoutes } from './routes/auth';
|
| 104 |
+
|
| 105 |
// ── Public Routes (no auth) ────────────────────────────────────────────────────
|
| 106 |
+
server.register(authRoutes, { prefix: '/v1/auth' });
|
| 107 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
| 108 |
server.register(studentRoutes, { prefix: '/v1/student' });
|
| 109 |
|
| 110 |
// ── Stripe Webhook (public — Stripe can't send API Key, verified by signature) ─
|
| 111 |
server.register(stripeWebhookRoute, { prefix: '/v1/payments' });
|
| 112 |
|
| 113 |
+
// ── Private Routes (guarded by API Key or JWT) ─────────────────────────────────
|
| 114 |
server.register(async function guardedRoutes(scope) {
|
| 115 |
scope.addHook('onRequest', async (request, reply) => {
|
|
|
|
| 116 |
if (request.method === 'OPTIONS') return;
|
| 117 |
|
| 118 |
const apiKey = process.env.ADMIN_API_KEY;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
const authHeader = request.headers['authorization'];
|
| 120 |
+
|
| 121 |
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
| 122 |
return reply.code(401).send({ error: 'Unauthorized', message: 'Missing Authorization header' });
|
| 123 |
}
|
| 124 |
|
| 125 |
const token = authHeader.slice(7);
|
| 126 |
+
|
| 127 |
+
// 1. Try Legacy API Key (Super Admin)
|
| 128 |
+
if (apiKey && token === apiKey) {
|
| 129 |
+
request.user = {
|
| 130 |
+
id: 'super-admin',
|
| 131 |
+
organizationId: request.headers['x-organization-id'] as string || 'default-org-id',
|
| 132 |
+
role: 'SUPER_ADMIN'
|
| 133 |
+
};
|
| 134 |
+
}
|
| 135 |
+
// 2. Try JWT (Organization Admin/Member)
|
| 136 |
+
else {
|
| 137 |
+
try {
|
| 138 |
+
const decoded = await request.jwtVerify() as any;
|
| 139 |
+
request.user = decoded;
|
| 140 |
+
} catch (err) {
|
| 141 |
+
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid token or API key' });
|
| 142 |
+
}
|
| 143 |
}
|
| 144 |
|
| 145 |
+
// 🏢 Multi-Tenant Enforcement
|
| 146 |
+
const requestedOrgId = request.headers['x-organization-id'] as string;
|
| 147 |
|
| 148 |
+
// Ensure requested Org matches User's Org (unless Super Admin)
|
| 149 |
+
if (request.user.role !== 'SUPER_ADMIN' && requestedOrgId && requestedOrgId !== request.user.organizationId) {
|
| 150 |
+
return reply.code(403).send({ error: 'Forbidden', message: 'You do not have access to this organization' });
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Auto-inject OrgId into request if missing and available in token
|
| 154 |
+
if (!requestedOrgId && request.user.organizationId) {
|
| 155 |
+
request.headers['x-organization-id'] = request.user.organizationId;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// Final check for routes requiring an organization
|
| 159 |
const isOrgIndependentRoute = request.url.startsWith('/v1/organizations') || request.url.startsWith('/v1/internal');
|
| 160 |
+
if (!request.headers['x-organization-id'] && !isOrgIndependentRoute) {
|
| 161 |
+
return reply.code(400).send({ error: 'Bad Request', message: 'Missing organization context' });
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
+
if (request.headers['x-organization-id']) {
|
| 165 |
+
request.log.info(`[CONTEXT] Setting Organization Context: ${request.headers['x-organization-id']}`);
|
| 166 |
}
|
| 167 |
});
|
| 168 |
|
apps/api/src/routes/auth.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { z } from 'zod';
|
| 3 |
+
import { AuthService } from '../services/auth';
|
| 4 |
+
import { logger } from '../logger';
|
| 5 |
+
|
| 6 |
+
export async function authRoutes(fastify: FastifyInstance) {
|
| 7 |
+
|
| 8 |
+
// Login with Email/Password
|
| 9 |
+
fastify.post('/login', {
|
| 10 |
+
schema: {
|
| 11 |
+
body: z.object({
|
| 12 |
+
email: z.string().email(),
|
| 13 |
+
password: z.string()
|
| 14 |
+
})
|
| 15 |
+
}
|
| 16 |
+
}, async (request, reply) => {
|
| 17 |
+
const { email, password } = request.body as any;
|
| 18 |
+
|
| 19 |
+
const user = await AuthService.findUserByEmail(email);
|
| 20 |
+
|
| 21 |
+
if (!user || !user.passwordHash) {
|
| 22 |
+
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const isValid = await AuthService.verifyPassword(password, user.passwordHash);
|
| 26 |
+
if (!isValid) {
|
| 27 |
+
return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid email or password' });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Generate JWT
|
| 31 |
+
const token = fastify.jwt.sign({
|
| 32 |
+
id: user.id,
|
| 33 |
+
organizationId: user.organizationId,
|
| 34 |
+
role: user.role
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
logger.info(`[AUTH] User ${email} logged in successfully for org ${user.organization?.name}`);
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
token,
|
| 41 |
+
user: {
|
| 42 |
+
id: user.id,
|
| 43 |
+
name: user.name,
|
| 44 |
+
email: user.email,
|
| 45 |
+
role: user.role,
|
| 46 |
+
organizationId: user.organizationId,
|
| 47 |
+
organization: user.organization
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
// Get current user profile (guarded by JWT)
|
| 53 |
+
fastify.get('/me', async (request, reply) => {
|
| 54 |
+
const { id } = request.user;
|
| 55 |
+
|
| 56 |
+
const user = await fastify.prisma.user.findUnique({
|
| 57 |
+
where: { id },
|
| 58 |
+
include: { organization: true }
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
if (!user) {
|
| 62 |
+
return reply.code(404).send({ error: 'Not Found', message: 'User not found' });
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
user: {
|
| 67 |
+
id: user.id,
|
| 68 |
+
name: user.name,
|
| 69 |
+
email: user.email,
|
| 70 |
+
role: user.role,
|
| 71 |
+
organizationId: user.organizationId,
|
| 72 |
+
organization: user.organization
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
});
|
| 76 |
+
}
|
apps/api/src/routes/organizations.ts
CHANGED
|
@@ -2,8 +2,11 @@ import { FastifyInstance } from 'fastify';
|
|
| 2 |
import { prisma } from '../services/prisma';
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { logger } from '../logger';
|
| 5 |
-
import {
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'default-secret-at-least-32-chars-long-!!!';
|
| 9 |
|
|
@@ -23,22 +26,10 @@ function decryptSecrets(org: any) {
|
|
| 23 |
return org;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
const
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
mode: z.enum(['EDTECH', 'WEBHOOK', 'AI_AGENT']).optional(),
|
| 31 |
-
flowConfig: FlowConfigSchema.optional(),
|
| 32 |
-
webhookUrl: z.string().url().optional().or(z.literal('')),
|
| 33 |
-
webhookSecret: z.string().optional(),
|
| 34 |
-
knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
|
| 35 |
-
systemUserToken: z.string().optional(),
|
| 36 |
-
brandingData: z.any().optional(),
|
| 37 |
-
contractSigned: z.boolean().optional(),
|
| 38 |
-
contractSignedAt: z.string().optional(),
|
| 39 |
-
contractSignerName: z.string().optional(),
|
| 40 |
-
metaVerificationStatus: z.string().optional(),
|
| 41 |
-
dailyMessageLimit: z.number().optional()
|
| 42 |
});
|
| 43 |
|
| 44 |
const PersonalityConfigSchema = z.object({
|
|
@@ -75,16 +66,61 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 75 |
return decryptSecrets(org);
|
| 76 |
});
|
| 77 |
|
| 78 |
-
// 3. Create a new organization
|
| 79 |
fastify.post('/', async (req, reply) => {
|
| 80 |
-
const body =
|
| 81 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 82 |
|
| 83 |
-
const
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
});
|
| 87 |
-
return reply.code(201).send(decryptSecrets(org));
|
| 88 |
});
|
| 89 |
|
| 90 |
// 4. Update organization (Branding, Prompt, etc.)
|
|
|
|
| 2 |
import { prisma } from '../services/prisma';
|
| 3 |
import { z } from 'zod';
|
| 4 |
import { logger } from '../logger';
|
| 5 |
+
import { scheduleEmail } from '../services/queue';
|
| 6 |
+
import { decryptSecrets, encryptSecrets, invalidateOrganizationCache } from '../services/organization';
|
| 7 |
+
import { FlowConfigSchema, OrganizationSchema, encrypt, decrypt } from '@repo/shared-types';
|
| 8 |
+
import { AuthService } from '../services/auth';
|
| 9 |
+
import { EmailService } from '../services/email';
|
| 10 |
|
| 11 |
const ENCRYPTION_SECRET = process.env.ENCRYPTION_SECRET || 'default-secret-at-least-32-chars-long-!!!';
|
| 12 |
|
|
|
|
| 26 |
return org;
|
| 27 |
}
|
| 28 |
|
| 29 |
+
const OrganizationCreationSchema = OrganizationSchema.extend({
|
| 30 |
+
slug: z.string().min(3).regex(/^[a-z0-9-]+$/),
|
| 31 |
+
adminEmail: z.string().email(),
|
| 32 |
+
adminName: z.string().min(2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
});
|
| 34 |
|
| 35 |
const PersonalityConfigSchema = z.object({
|
|
|
|
| 66 |
return decryptSecrets(org);
|
| 67 |
});
|
| 68 |
|
| 69 |
+
// 3. Create a new organization + First Admin
|
| 70 |
fastify.post('/', async (req, reply) => {
|
| 71 |
+
const body = OrganizationCreationSchema.safeParse(req.body);
|
| 72 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 73 |
|
| 74 |
+
const { adminEmail, adminName, slug, ...orgData } = body.data;
|
| 75 |
+
|
| 76 |
+
// Check if slug already exists
|
| 77 |
+
const existing = await prisma.organization.findUnique({ where: { slug } });
|
| 78 |
+
if (existing) return reply.code(400).send({ error: 'Slug already taken' });
|
| 79 |
+
|
| 80 |
+
const data = encryptSecrets(orgData);
|
| 81 |
+
|
| 82 |
+
// Use a transaction to ensure both Org and User are created
|
| 83 |
+
const result = await prisma.$transaction(async (tx) => {
|
| 84 |
+
const org = await tx.organization.create({
|
| 85 |
+
data: { ...data, slug }
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
// Temporary password (user will reset it)
|
| 89 |
+
const tempPassword = Math.random().toString(36).slice(-10);
|
| 90 |
+
const passwordHash = await AuthService.hashPassword(tempPassword);
|
| 91 |
+
|
| 92 |
+
const user = await tx.user.create({
|
| 93 |
+
data: {
|
| 94 |
+
email: adminEmail,
|
| 95 |
+
name: adminName,
|
| 96 |
+
passwordHash,
|
| 97 |
+
role: 'ORG_ADMIN',
|
| 98 |
+
organizationId: org.id
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
return { org, user, tempPassword };
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
// Send Welcome Email (async via BullMQ)
|
| 106 |
+
const loginUrl = `https://${slug}.xamle.studio/login`;
|
| 107 |
+
const resetUrl = `https://${slug}.xamle.studio/reset-password`;
|
| 108 |
+
|
| 109 |
+
await scheduleEmail({
|
| 110 |
+
to: adminEmail,
|
| 111 |
+
subject: `Bienvenue chez Xamlé Studio - ${result.org.name}`,
|
| 112 |
+
params: { name: adminName, organizationName: result.org.name, loginUrl, resetUrl },
|
| 113 |
+
templateId: 1 // We'll handle mapping in the worker
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
return reply.code(201).send({
|
| 117 |
+
organization: decryptSecrets(result.org),
|
| 118 |
+
admin: {
|
| 119 |
+
id: result.user.id,
|
| 120 |
+
email: result.user.email,
|
| 121 |
+
tempPassword: result.tempPassword // We show it once in the response for convenience
|
| 122 |
+
}
|
| 123 |
});
|
|
|
|
| 124 |
});
|
| 125 |
|
| 126 |
// 4. Update organization (Branding, Prompt, etc.)
|
apps/api/src/services/auth.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt from 'bcrypt';
|
| 2 |
+
import { prisma } from './prisma';
|
| 3 |
+
import { logger } from '../logger';
|
| 4 |
+
|
| 5 |
+
const SALT_ROUNDS = 10;
|
| 6 |
+
|
| 7 |
+
export class AuthService {
|
| 8 |
+
/**
|
| 9 |
+
* Hashes a password using bcrypt.
|
| 10 |
+
*/
|
| 11 |
+
static async hashPassword(password: string): Promise<string> {
|
| 12 |
+
return bcrypt.hash(password, SALT_ROUNDS);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Compares a plaintext password with a hashed password.
|
| 17 |
+
*/
|
| 18 |
+
static async verifyPassword(password: string, hash: string): Promise<boolean> {
|
| 19 |
+
return bcrypt.compare(password, hash);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Finds a user by email and includes organization context.
|
| 24 |
+
*/
|
| 25 |
+
static async findUserByEmail(email: string) {
|
| 26 |
+
return prisma.user.findUnique({
|
| 27 |
+
where: { email },
|
| 28 |
+
include: { organization: true }
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Checks if a user is allowed to access an organization.
|
| 34 |
+
*/
|
| 35 |
+
static isUserAllowedInOrg(user: any, targetOrgId: string): boolean {
|
| 36 |
+
// Super admin can access anything
|
| 37 |
+
if (user.role === 'SUPER_ADMIN') return true;
|
| 38 |
+
|
| 39 |
+
// Org Admin/Member must match the ID
|
| 40 |
+
return user.organizationId === targetOrgId;
|
| 41 |
+
}
|
| 42 |
+
}
|
apps/api/src/services/email.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
import { logger } from '../logger';
|
| 3 |
+
|
| 4 |
+
const BREVO_API_KEY = process.env.BREVO_API_KEY;
|
| 5 |
+
const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email';
|
| 6 |
+
|
| 7 |
+
export class EmailService {
|
| 8 |
+
static async sendWelcomeEmail(to: string, name: string, organizationName: string, loginUrl: string, passwordResetUrl: string) {
|
| 9 |
+
if (!BREVO_API_KEY) {
|
| 10 |
+
logger.warn('[EMAIL] BREVO_API_KEY not found. Skipping email sending.');
|
| 11 |
+
return;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
const response = await axios.post(
|
| 16 |
+
BREVO_API_URL,
|
| 17 |
+
{
|
| 18 |
+
sender: { name: 'Xamlé Studio', email: 'contact@xamle.studio' },
|
| 19 |
+
to: [{ email: to, name }],
|
| 20 |
+
subject: `Bienvenue chez Xamlé Studio - ${organizationName}`,
|
| 21 |
+
htmlContent: `
|
| 22 |
+
<div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
| 23 |
+
<h2 style="color: #1e293b;">Bienvenue, ${name} !</h2>
|
| 24 |
+
<p>Votre espace pour <strong>${organizationName}</strong> a été créé avec succès.</p>
|
| 25 |
+
<p>Vous pouvez vous connecter à votre tableau de bord en cliquant sur le bouton ci-dessous :</p>
|
| 26 |
+
<a href="${loginUrl}" style="display: inline-block; padding: 12px 24px; background-color: #059669; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; margin: 20px 0;">Accéder au Dashboard</a>
|
| 27 |
+
<p>Pour des raisons de sécurité, nous vous recommandons de configurer votre mot de passe immédiatement via ce lien :</p>
|
| 28 |
+
<a href="${passwordResetUrl}">${passwordResetUrl}</a>
|
| 29 |
+
<p style="color: #64748b; font-size: 0.875rem; margin-top: 40px;">L'équipe Xamlé Studio</p>
|
| 30 |
+
</div>
|
| 31 |
+
`
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
headers: {
|
| 35 |
+
'api-key': BREVO_API_KEY,
|
| 36 |
+
'Content-Type': 'application/json'
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
logger.info(`[EMAIL] Welcome email sent to ${to} (Brevo ID: ${response.data.messageId})`);
|
| 42 |
+
} catch (error: any) {
|
| 43 |
+
logger.error(`[EMAIL] Failed to send welcome email to ${to}: ${error.response?.data?.message || error.message}`);
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
static async sendInvitationEmail(to: string, invitedBy: string, organizationName: string, joinUrl: string) {
|
| 48 |
+
if (!BREVO_API_KEY) {
|
| 49 |
+
logger.warn('[EMAIL] BREVO_API_KEY not found. Skipping email sending.');
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
await axios.post(
|
| 55 |
+
BREVO_API_URL,
|
| 56 |
+
{
|
| 57 |
+
sender: { name: 'Xamlé Studio', email: 'contact@xamle.studio' },
|
| 58 |
+
to: [{ email: to }],
|
| 59 |
+
subject: `${invitedBy} vous invite à rejoindre ${organizationName}`,
|
| 60 |
+
htmlContent: `
|
| 61 |
+
<div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
| 62 |
+
<h2 style="color: #1e293b;">Rejoignez votre équipe !</h2>
|
| 63 |
+
<p><strong>${invitedBy}</strong> vous a invité à collaborer sur l'espace <strong>${organizationName}</strong>.</p>
|
| 64 |
+
<p>Cliquez ci-dessous pour activer votre compte :</p>
|
| 65 |
+
<a href="${joinUrl}" style="display: inline-block; padding: 12px 24px; background-color: #059669; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; margin: 20px 0;">Rejoindre l'équipe</a>
|
| 66 |
+
<p style="color: #64748b; font-size: 0.875rem; margin-top: 40px;">L'équipe Xamlé Studio</p>
|
| 67 |
+
</div>
|
| 68 |
+
`
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
headers: {
|
| 72 |
+
'api-key': BREVO_API_KEY,
|
| 73 |
+
'Content-Type': 'application/json'
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
);
|
| 77 |
+
logger.info(`[EMAIL] Invitation sent to ${to}`);
|
| 78 |
+
} catch (error: any) {
|
| 79 |
+
logger.error(`[EMAIL] Failed to send invitation to ${to}: ${error.response?.data?.message || error.message}`);
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
}
|
apps/api/src/services/queue.ts
CHANGED
|
@@ -14,6 +14,21 @@ const connection = process.env.REDIS_URL
|
|
| 14 |
});
|
| 15 |
|
| 16 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
// ─── Time-Travel Context (Redis overlay for historical lesson replay) ────────
|
| 19 |
export const redis = connection; // Shared connection for time-travel ops
|
|
|
|
| 14 |
});
|
| 15 |
|
| 16 |
export const whatsappQueue = new Queue('whatsapp-queue', { connection: connection as any });
|
| 17 |
+
export const notificationQueue = new Queue('notification-queue', { connection: connection as any });
|
| 18 |
+
|
| 19 |
+
/** Schedule an email notification */
|
| 20 |
+
export async function scheduleEmail(payload: {
|
| 21 |
+
to: string,
|
| 22 |
+
subject: string,
|
| 23 |
+
templateId?: number,
|
| 24 |
+
params?: Record<string, any>,
|
| 25 |
+
htmlContent?: string
|
| 26 |
+
}) {
|
| 27 |
+
await notificationQueue.add('send-email', payload, {
|
| 28 |
+
attempts: 3,
|
| 29 |
+
backoff: { type: 'exponential', delay: 1000 }
|
| 30 |
+
});
|
| 31 |
+
}
|
| 32 |
|
| 33 |
// ─── Time-Travel Context (Redis overlay for historical lesson replay) ────────
|
| 34 |
export const redis = connection; // Shared connection for time-travel ops
|
apps/whatsapp-worker/src/handlers/EmailHandler.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Job } from 'bullmq';
|
| 2 |
+
import { JobHandler } from './types';
|
| 3 |
+
import { logger } from '../logger';
|
| 4 |
+
import { EmailService } from '../services/email';
|
| 5 |
+
|
| 6 |
+
export class EmailHandler implements JobHandler {
|
| 7 |
+
async handle(job: Job<any>): Promise<void> {
|
| 8 |
+
const { to, subject, params, templateId, htmlContent } = job.data;
|
| 9 |
+
|
| 10 |
+
logger.info(`[EMAIL_HANDLER] Processing email for ${to}`);
|
| 11 |
+
|
| 12 |
+
if (htmlContent) {
|
| 13 |
+
await EmailService.sendEmail({ to, subject, htmlContent });
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Handle specific templates
|
| 18 |
+
if (templateId === 1) { // Welcome Email
|
| 19 |
+
const { name, organizationName, loginUrl, resetUrl } = params;
|
| 20 |
+
await EmailService.sendWelcomeEmail(to, name, organizationName, loginUrl, resetUrl);
|
| 21 |
+
} else {
|
| 22 |
+
logger.warn(`[EMAIL_HANDLER] Unknown templateId: ${templateId}`);
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
}
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { EnrollHandler } from './handlers/EnrollHandler';
|
|
| 24 |
import { InboundHandler } from './handlers/InboundHandler';
|
| 25 |
import { WebhookHandler } from './handlers/WebhookHandler';
|
| 26 |
import { KBProcessor } from './handlers/KBProcessor';
|
|
|
|
| 27 |
import { UsageService } from './services/usage';
|
| 28 |
|
| 29 |
dotenv.config();
|
|
@@ -59,7 +60,8 @@ const handlers: Record<string, JobHandler> = {
|
|
| 59 |
'enroll-user': new EnrollHandler(),
|
| 60 |
'handle-inbound': new InboundHandler(),
|
| 61 |
'send-webhook': new WebhookHandler(),
|
| 62 |
-
'process-kb': new KBProcessor()
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
// ─── HTTP SERVER (Inbound Bridge) ─────────────────────────────────────────────
|
|
@@ -208,3 +210,29 @@ worker.on('completed', job => {
|
|
| 208 |
worker.on('failed', (job, err) => {
|
| 209 |
logger.error(`[WORKER] Job ${job?.id} failed: ${err.message}`);
|
| 210 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
import { InboundHandler } from './handlers/InboundHandler';
|
| 25 |
import { WebhookHandler } from './handlers/WebhookHandler';
|
| 26 |
import { KBProcessor } from './handlers/KBProcessor';
|
| 27 |
+
import { EmailHandler } from './handlers/EmailHandler';
|
| 28 |
import { UsageService } from './services/usage';
|
| 29 |
|
| 30 |
dotenv.config();
|
|
|
|
| 60 |
'enroll-user': new EnrollHandler(),
|
| 61 |
'handle-inbound': new InboundHandler(),
|
| 62 |
'send-webhook': new WebhookHandler(),
|
| 63 |
+
'process-kb': new KBProcessor(),
|
| 64 |
+
'send-email': new EmailHandler()
|
| 65 |
};
|
| 66 |
|
| 67 |
// ─── HTTP SERVER (Inbound Bridge) ─────────────────────────────────────────────
|
|
|
|
| 210 |
worker.on('failed', (job, err) => {
|
| 211 |
logger.error(`[WORKER] Job ${job?.id} failed: ${err.message}`);
|
| 212 |
});
|
| 213 |
+
|
| 214 |
+
const notificationWorker = new Worker('notification-queue', async (job: Job<any>) => {
|
| 215 |
+
logger.info(`[NOTIFICATION_WORKER] Processing job: ${job.name} (${job.id})`);
|
| 216 |
+
try {
|
| 217 |
+
const handler = handlers[job.name];
|
| 218 |
+
if (handler) {
|
| 219 |
+
await handler.handle(job, connection);
|
| 220 |
+
} else {
|
| 221 |
+
logger.warn(`[NOTIFICATION_WORKER] No handler found for job name: ${job.name}`);
|
| 222 |
+
}
|
| 223 |
+
} catch (err) {
|
| 224 |
+
logger.error(`[NOTIFICATION_WORKER] Job ${job.id} failed:`, err);
|
| 225 |
+
throw err;
|
| 226 |
+
}
|
| 227 |
+
}, {
|
| 228 |
+
connection: connection as any,
|
| 229 |
+
concurrency: 2
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
notificationWorker.on('completed', job => {
|
| 233 |
+
logger.info(`[NOTIFICATION_WORKER] Job ${job.id} has completed!`);
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
notificationWorker.on('failed', (job, err) => {
|
| 237 |
+
logger.error(`[NOTIFICATION_WORKER] Job ${job?.id} failed: ${err?.message}`);
|
| 238 |
+
});
|
apps/whatsapp-worker/src/services/email.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios';
|
| 2 |
+
import { logger } from '../logger';
|
| 3 |
+
|
| 4 |
+
const BREVO_API_KEY = process.env.BREVO_API_KEY;
|
| 5 |
+
const BREVO_API_URL = 'https://api.brevo.com/v3/smtp/email';
|
| 6 |
+
|
| 7 |
+
export class EmailService {
|
| 8 |
+
static async sendEmail(payload: { to: string, subject: string, htmlContent: string }) {
|
| 9 |
+
if (!BREVO_API_KEY) {
|
| 10 |
+
logger.warn('[EMAIL] BREVO_API_KEY not found. Skipping email sending.');
|
| 11 |
+
return;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
try {
|
| 15 |
+
const response = await axios.post(
|
| 16 |
+
BREVO_API_URL,
|
| 17 |
+
{
|
| 18 |
+
sender: { name: 'Xamlé Studio', email: 'contact@xamle.studio' },
|
| 19 |
+
to: [{ email: payload.to }],
|
| 20 |
+
subject: payload.subject,
|
| 21 |
+
htmlContent: payload.htmlContent
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
headers: {
|
| 25 |
+
'api-key': BREVO_API_KEY,
|
| 26 |
+
'Content-Type': 'application/json'
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
logger.info(`[EMAIL] Email sent to ${payload.to} (Brevo ID: ${response.data.messageId})`);
|
| 32 |
+
} catch (error: any) {
|
| 33 |
+
logger.error(`[EMAIL] Failed to send email to ${payload.to}: ${error.response?.data?.message || error.message}`);
|
| 34 |
+
throw error; // Re-throw to trigger BullMQ retry
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** Helper for welcome email */
|
| 39 |
+
static async sendWelcomeEmail(to: string, name: string, organizationName: string, loginUrl: string, passwordResetUrl: string) {
|
| 40 |
+
const html = `
|
| 41 |
+
<div style="font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; border: 1px solid #eee; border-radius: 10px;">
|
| 42 |
+
<h2 style="color: #1e293b;">Bienvenue, ${name} !</h2>
|
| 43 |
+
<p>Votre espace pour <strong>${organizationName}</strong> a été créé avec succès.</p>
|
| 44 |
+
<p>Vous pouvez vous connecter à votre tableau de bord en cliquant sur le bouton ci-dessous :</p>
|
| 45 |
+
<a href="${loginUrl}" style="display: inline-block; padding: 12px 24px; background-color: #059669; color: white; text-decoration: none; border-radius: 8px; font-weight: bold; margin: 20px 0;">Accéder au Dashboard</a>
|
| 46 |
+
<p>Pour des raisons de sécurité, nous vous recommandons de configurer votre mot de passe immédiatement via ce lien :</p>
|
| 47 |
+
<a href="${passwordResetUrl}">${passwordResetUrl}</a>
|
| 48 |
+
<p style="color: #64748b; font-size: 0.875rem; margin-top: 40px;">L'équipe Xamlé Studio</p>
|
| 49 |
+
</div>
|
| 50 |
+
`;
|
| 51 |
+
await this.sendEmail({ to, subject: `Bienvenue chez Xamlé Studio - ${organizationName}`, htmlContent: html });
|
| 52 |
+
}
|
| 53 |
+
}
|
apps/whatsapp-worker/src/services/whatsapp-logic.ts
CHANGED
|
@@ -45,7 +45,9 @@ export class WhatsAppLogic {
|
|
| 45 |
logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
|
| 46 |
|
| 47 |
// 1. Find User, Enrollment & Organization
|
| 48 |
-
const user = await prisma.user.
|
|
|
|
|
|
|
| 49 |
const activeEnrollment = user ? await prisma.enrollment.findFirst({
|
| 50 |
where: { userId: user.id, status: 'ACTIVE' },
|
| 51 |
include: { track: true }
|
|
|
|
| 45 |
logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
|
| 46 |
|
| 47 |
// 1. Find User, Enrollment & Organization
|
| 48 |
+
const user = await prisma.user.findFirst({
|
| 49 |
+
where: { phone, organizationId }
|
| 50 |
+
});
|
| 51 |
const activeEnrollment = user ? await prisma.enrollment.findFirst({
|
| 52 |
where: { userId: user.id, status: 'ACTIVE' },
|
| 53 |
include: { track: true }
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -8,81 +8,88 @@ datasource db {
|
|
| 8 |
}
|
| 9 |
|
| 10 |
model Organization {
|
| 11 |
-
id
|
| 12 |
-
name
|
| 13 |
-
wabaId
|
| 14 |
-
systemUserToken
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
messages
|
| 32 |
-
payments
|
| 33 |
-
responses
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
userBadges
|
| 39 |
-
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
model KnowledgeBaseEntry {
|
| 43 |
-
id String
|
| 44 |
organizationId String
|
| 45 |
-
content String
|
| 46 |
-
embedding Unsupported("vector
|
| 47 |
metadata Json?
|
| 48 |
-
|
| 49 |
-
|
| 50 |
|
| 51 |
@@index([organizationId])
|
| 52 |
}
|
| 53 |
|
| 54 |
model WhatsAppPhoneNumber {
|
| 55 |
-
id String @id
|
| 56 |
displayPhone String
|
| 57 |
organizationId String
|
| 58 |
-
organization Organization @relation(fields: [organizationId], references: [id])
|
| 59 |
createdAt DateTime @default(now())
|
| 60 |
updatedAt DateTime @updatedAt
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
model User {
|
| 64 |
id String @id @default(uuid())
|
| 65 |
-
phone String
|
| 66 |
name String?
|
| 67 |
role Role @default(STUDENT)
|
| 68 |
language Language @default(FR)
|
| 69 |
city String?
|
| 70 |
activity String?
|
| 71 |
-
organizationId String @default("default-org-id")
|
| 72 |
createdAt DateTime @default(now())
|
| 73 |
updatedAt DateTime @updatedAt
|
|
|
|
|
|
|
| 74 |
currentStreak Int @default(0)
|
| 75 |
longestStreak Int @default(0)
|
| 76 |
lastActivityAt DateTime?
|
|
|
|
| 77 |
businessProfile BusinessProfile?
|
| 78 |
-
organization Organization @relation(fields: [organizationId], references: [id])
|
| 79 |
enrollments Enrollment[]
|
| 80 |
messages Message[]
|
| 81 |
payments Payment[]
|
| 82 |
responses Response[]
|
|
|
|
| 83 |
progress UserProgress[]
|
| 84 |
|
|
|
|
|
|
|
| 85 |
@@index([organizationId])
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
model BusinessProfile {
|
|
@@ -101,13 +108,13 @@ model BusinessProfile {
|
|
| 101 |
financialProjections Json?
|
| 102 |
fundingAsk String?
|
| 103 |
lastUpdatedFromDay Int @default(0)
|
| 104 |
-
organizationId String @default("default-org-id")
|
| 105 |
createdAt DateTime @default(now())
|
| 106 |
updatedAt DateTime @updatedAt
|
| 107 |
teamMembers Json? @map("teamMembers")
|
| 108 |
-
|
| 109 |
-
user User @relation(fields: [userId], references: [id])
|
| 110 |
organization Organization @relation(fields: [organizationId], references: [id])
|
|
|
|
|
|
|
| 111 |
|
| 112 |
@@index([organizationId])
|
| 113 |
}
|
|
@@ -134,12 +141,12 @@ model Track {
|
|
| 134 |
isPremium Boolean @default(false)
|
| 135 |
priceAmount Int?
|
| 136 |
stripePriceId String?
|
| 137 |
-
organizationId String @default("default-org-id")
|
| 138 |
createdAt DateTime @default(now())
|
| 139 |
updatedAt DateTime @updatedAt
|
| 140 |
-
|
| 141 |
enrollments Enrollment[]
|
| 142 |
payments Payment[]
|
|
|
|
| 143 |
days TrackDay[]
|
| 144 |
progress UserProgress[]
|
| 145 |
|
|
@@ -163,11 +170,11 @@ model TrackDay {
|
|
| 163 |
exerciseCriteria Json?
|
| 164 |
badges Json?
|
| 165 |
unlockCondition String?
|
| 166 |
-
organizationId String @default("default-org-id")
|
| 167 |
createdAt DateTime @default(now())
|
| 168 |
updatedAt DateTime @updatedAt
|
| 169 |
-
|
| 170 |
organization Organization @relation(fields: [organizationId], references: [id])
|
|
|
|
| 171 |
|
| 172 |
@@index([organizationId])
|
| 173 |
}
|
|
@@ -180,17 +187,17 @@ model UserProgress {
|
|
| 180 |
lastInteraction DateTime @default(now())
|
| 181 |
exerciseStatus ExerciseStatus @default(PENDING)
|
| 182 |
badges Json? @map("badges")
|
| 183 |
-
userBadges UserBadge[]
|
| 184 |
behavioralScoring Json?
|
| 185 |
confidenceScore Float?
|
| 186 |
adminTranscription String?
|
| 187 |
overrideAudioUrl String?
|
| 188 |
reviewedBy String?
|
| 189 |
-
organizationId String @default("default-org-id")
|
| 190 |
createdAt DateTime @default(now())
|
| 191 |
updatedAt DateTime @updatedAt
|
| 192 |
iterationCount Int @default(0)
|
| 193 |
aiSource String?
|
|
|
|
|
|
|
| 194 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 195 |
track Track @relation(fields: [trackId], references: [id])
|
| 196 |
user User @relation(fields: [userId], references: [id])
|
|
@@ -224,9 +231,9 @@ model Response {
|
|
| 224 |
dayNumber Int
|
| 225 |
content String?
|
| 226 |
mediaUrl String?
|
| 227 |
-
organizationId String @default("default-org-id")
|
| 228 |
createdAt DateTime @default(now())
|
| 229 |
aiSource String?
|
|
|
|
| 230 |
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
| 231 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 232 |
user User @relation(fields: [userId], references: [id])
|
|
@@ -242,8 +249,8 @@ model Message {
|
|
| 242 |
content String?
|
| 243 |
mediaUrl String?
|
| 244 |
payload Json?
|
| 245 |
-
organizationId String @default("default-org-id")
|
| 246 |
createdAt DateTime @default(now())
|
|
|
|
| 247 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 248 |
user User @relation(fields: [userId], references: [id])
|
| 249 |
|
|
@@ -259,9 +266,9 @@ model Payment {
|
|
| 259 |
currency String @default("XOF")
|
| 260 |
status PaymentStatus @default(PENDING)
|
| 261 |
stripeSessionId String? @unique
|
| 262 |
-
organizationId String @default("default-org-id")
|
| 263 |
createdAt DateTime @default(now())
|
| 264 |
updatedAt DateTime @updatedAt
|
|
|
|
| 265 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 266 |
track Track @relation(fields: [trackId], references: [id])
|
| 267 |
user User @relation(fields: [userId], references: [id])
|
|
@@ -281,9 +288,24 @@ model TrainingData {
|
|
| 281 |
updatedAt DateTime @updatedAt
|
| 282 |
}
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
enum Role {
|
| 285 |
STUDENT
|
| 286 |
ADMIN
|
|
|
|
|
|
|
|
|
|
| 287 |
}
|
| 288 |
|
| 289 |
enum OrganizationMode {
|
|
@@ -341,15 +363,3 @@ enum TrainingStatus {
|
|
| 341 |
REVIEWED
|
| 342 |
IGNORED
|
| 343 |
}
|
| 344 |
-
|
| 345 |
-
model UserBadge {
|
| 346 |
-
id String @id @default(uuid())
|
| 347 |
-
userProgressId String
|
| 348 |
-
name String
|
| 349 |
-
earnedAt DateTime @default(now())
|
| 350 |
-
organizationId String @default("default-org-id")
|
| 351 |
-
userProgress UserProgress @relation(fields: [userProgressId], references: [id], onDelete: Cascade)
|
| 352 |
-
organization Organization @relation(fields: [organizationId], references: [id])
|
| 353 |
-
|
| 354 |
-
@@index([organizationId])
|
| 355 |
-
}
|
|
|
|
| 8 |
}
|
| 9 |
|
| 10 |
model Organization {
|
| 11 |
+
id String @id @default(uuid())
|
| 12 |
+
name String
|
| 13 |
+
wabaId String? @unique
|
| 14 |
+
systemUserToken String?
|
| 15 |
+
createdAt DateTime @default(now())
|
| 16 |
+
updatedAt DateTime @updatedAt
|
| 17 |
+
slug String? @unique // For subdomains like polytech.xamle.studio
|
| 18 |
+
brandingData Json?
|
| 19 |
+
customPrompt String?
|
| 20 |
+
personalityConfig Json?
|
| 21 |
+
flowConfig Json?
|
| 22 |
+
knowledgeBaseUrl String?
|
| 23 |
+
mode OrganizationMode @default(EDTECH)
|
| 24 |
+
webhookSecret String?
|
| 25 |
+
webhookUrl String?
|
| 26 |
+
stripeCustomerId String?
|
| 27 |
+
subscriptionStatus String? @default("ACTIVE")
|
| 28 |
+
businessProfiles BusinessProfile[]
|
| 29 |
+
enrollments Enrollment[]
|
| 30 |
+
kbEntries KnowledgeBaseEntry[]
|
| 31 |
+
messages Message[]
|
| 32 |
+
payments Payment[]
|
| 33 |
+
responses Response[]
|
| 34 |
+
teamMembers TeamMember[]
|
| 35 |
+
tracks Track[]
|
| 36 |
+
trackDays TrackDay[]
|
| 37 |
+
users User[]
|
| 38 |
+
userBadges UserBadge[]
|
| 39 |
+
progress UserProgress[]
|
| 40 |
+
phoneNumbers WhatsAppPhoneNumber[]
|
| 41 |
}
|
| 42 |
|
| 43 |
model KnowledgeBaseEntry {
|
| 44 |
+
id String @id @default(uuid())
|
| 45 |
organizationId String
|
| 46 |
+
content String
|
| 47 |
+
embedding Unsupported("vector")?
|
| 48 |
metadata Json?
|
| 49 |
+
createdAt DateTime @default(now())
|
| 50 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 51 |
|
| 52 |
@@index([organizationId])
|
| 53 |
}
|
| 54 |
|
| 55 |
model WhatsAppPhoneNumber {
|
| 56 |
+
id String @id
|
| 57 |
displayPhone String
|
| 58 |
organizationId String
|
|
|
|
| 59 |
createdAt DateTime @default(now())
|
| 60 |
updatedAt DateTime @updatedAt
|
| 61 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 62 |
}
|
| 63 |
|
| 64 |
model User {
|
| 65 |
id String @id @default(uuid())
|
| 66 |
+
phone String? // Made optional for Email-based admins
|
| 67 |
name String?
|
| 68 |
role Role @default(STUDENT)
|
| 69 |
language Language @default(FR)
|
| 70 |
city String?
|
| 71 |
activity String?
|
|
|
|
| 72 |
createdAt DateTime @default(now())
|
| 73 |
updatedAt DateTime @updatedAt
|
| 74 |
+
email String?
|
| 75 |
+
passwordHash String?
|
| 76 |
currentStreak Int @default(0)
|
| 77 |
longestStreak Int @default(0)
|
| 78 |
lastActivityAt DateTime?
|
| 79 |
+
organizationId String @default("default-org-id")
|
| 80 |
businessProfile BusinessProfile?
|
|
|
|
| 81 |
enrollments Enrollment[]
|
| 82 |
messages Message[]
|
| 83 |
payments Payment[]
|
| 84 |
responses Response[]
|
| 85 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 86 |
progress UserProgress[]
|
| 87 |
|
| 88 |
+
@@unique([phone, organizationId])
|
| 89 |
+
@@unique([email, organizationId])
|
| 90 |
@@index([organizationId])
|
| 91 |
+
@@index([phone])
|
| 92 |
+
@@index([email])
|
| 93 |
}
|
| 94 |
|
| 95 |
model BusinessProfile {
|
|
|
|
| 108 |
financialProjections Json?
|
| 109 |
fundingAsk String?
|
| 110 |
lastUpdatedFromDay Int @default(0)
|
|
|
|
| 111 |
createdAt DateTime @default(now())
|
| 112 |
updatedAt DateTime @updatedAt
|
| 113 |
teamMembers Json? @map("teamMembers")
|
| 114 |
+
organizationId String @default("default-org-id")
|
|
|
|
| 115 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 116 |
+
user User @relation(fields: [userId], references: [id])
|
| 117 |
+
teamMembersList TeamMember[]
|
| 118 |
|
| 119 |
@@index([organizationId])
|
| 120 |
}
|
|
|
|
| 141 |
isPremium Boolean @default(false)
|
| 142 |
priceAmount Int?
|
| 143 |
stripePriceId String?
|
|
|
|
| 144 |
createdAt DateTime @default(now())
|
| 145 |
updatedAt DateTime @updatedAt
|
| 146 |
+
organizationId String @default("default-org-id")
|
| 147 |
enrollments Enrollment[]
|
| 148 |
payments Payment[]
|
| 149 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 150 |
days TrackDay[]
|
| 151 |
progress UserProgress[]
|
| 152 |
|
|
|
|
| 170 |
exerciseCriteria Json?
|
| 171 |
badges Json?
|
| 172 |
unlockCondition String?
|
|
|
|
| 173 |
createdAt DateTime @default(now())
|
| 174 |
updatedAt DateTime @updatedAt
|
| 175 |
+
organizationId String @default("default-org-id")
|
| 176 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 177 |
+
track Track @relation(fields: [trackId], references: [id])
|
| 178 |
|
| 179 |
@@index([organizationId])
|
| 180 |
}
|
|
|
|
| 187 |
lastInteraction DateTime @default(now())
|
| 188 |
exerciseStatus ExerciseStatus @default(PENDING)
|
| 189 |
badges Json? @map("badges")
|
|
|
|
| 190 |
behavioralScoring Json?
|
| 191 |
confidenceScore Float?
|
| 192 |
adminTranscription String?
|
| 193 |
overrideAudioUrl String?
|
| 194 |
reviewedBy String?
|
|
|
|
| 195 |
createdAt DateTime @default(now())
|
| 196 |
updatedAt DateTime @updatedAt
|
| 197 |
iterationCount Int @default(0)
|
| 198 |
aiSource String?
|
| 199 |
+
organizationId String @default("default-org-id")
|
| 200 |
+
userBadges UserBadge[]
|
| 201 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 202 |
track Track @relation(fields: [trackId], references: [id])
|
| 203 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
| 231 |
dayNumber Int
|
| 232 |
content String?
|
| 233 |
mediaUrl String?
|
|
|
|
| 234 |
createdAt DateTime @default(now())
|
| 235 |
aiSource String?
|
| 236 |
+
organizationId String @default("default-org-id")
|
| 237 |
enrollment Enrollment @relation(fields: [enrollmentId], references: [id], onDelete: Cascade)
|
| 238 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 239 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
| 249 |
content String?
|
| 250 |
mediaUrl String?
|
| 251 |
payload Json?
|
|
|
|
| 252 |
createdAt DateTime @default(now())
|
| 253 |
+
organizationId String @default("default-org-id")
|
| 254 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 255 |
user User @relation(fields: [userId], references: [id])
|
| 256 |
|
|
|
|
| 266 |
currency String @default("XOF")
|
| 267 |
status PaymentStatus @default(PENDING)
|
| 268 |
stripeSessionId String? @unique
|
|
|
|
| 269 |
createdAt DateTime @default(now())
|
| 270 |
updatedAt DateTime @updatedAt
|
| 271 |
+
organizationId String @default("default-org-id")
|
| 272 |
organization Organization @relation(fields: [organizationId], references: [id])
|
| 273 |
track Track @relation(fields: [trackId], references: [id])
|
| 274 |
user User @relation(fields: [userId], references: [id])
|
|
|
|
| 288 |
updatedAt DateTime @updatedAt
|
| 289 |
}
|
| 290 |
|
| 291 |
+
model UserBadge {
|
| 292 |
+
id String @id @default(uuid())
|
| 293 |
+
userProgressId String
|
| 294 |
+
name String
|
| 295 |
+
earnedAt DateTime @default(now())
|
| 296 |
+
organizationId String @default("default-org-id")
|
| 297 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 298 |
+
userProgress UserProgress @relation(fields: [userProgressId], references: [id], onDelete: Cascade)
|
| 299 |
+
|
| 300 |
+
@@index([organizationId])
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
enum Role {
|
| 304 |
STUDENT
|
| 305 |
ADMIN
|
| 306 |
+
ORG_MEMBER
|
| 307 |
+
ORG_ADMIN
|
| 308 |
+
SUPER_ADMIN
|
| 309 |
}
|
| 310 |
|
| 311 |
enum OrganizationMode {
|
|
|
|
| 363 |
REVIEWED
|
| 364 |
IGNORED
|
| 365 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
packages/shared-types/src/index.ts
CHANGED
|
@@ -1,17 +1,32 @@
|
|
| 1 |
import { z } from 'zod';
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
export const UserSchema = z.object({
|
| 4 |
id: z.string().uuid(),
|
| 5 |
-
phone: z.string(),
|
|
|
|
| 6 |
name: z.string().optional(),
|
|
|
|
| 7 |
language: z.enum(['FR', 'WOLOF']).default('FR'),
|
| 8 |
city: z.string().optional(),
|
| 9 |
activity: z.string().optional(),
|
|
|
|
|
|
|
| 10 |
});
|
| 11 |
|
| 12 |
export type User = z.infer<typeof UserSchema>;
|
| 13 |
|
| 14 |
-
export const CreateUserSchema = UserSchema.pick({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
| 16 |
|
| 17 |
export const WebhookPayloadSchema = z.object({
|
|
|
|
| 1 |
import { z } from 'zod';
|
| 2 |
|
| 3 |
+
export const RoleSchema = z.enum(['STUDENT', 'ADMIN', 'ORG_MEMBER', 'ORG_ADMIN', 'SUPER_ADMIN']);
|
| 4 |
+
export type Role = z.infer<typeof RoleSchema>;
|
| 5 |
+
|
| 6 |
export const UserSchema = z.object({
|
| 7 |
id: z.string().uuid(),
|
| 8 |
+
phone: z.string().optional(),
|
| 9 |
+
email: z.string().email().optional(),
|
| 10 |
name: z.string().optional(),
|
| 11 |
+
role: RoleSchema.default('STUDENT'),
|
| 12 |
language: z.enum(['FR', 'WOLOF']).default('FR'),
|
| 13 |
city: z.string().optional(),
|
| 14 |
activity: z.string().optional(),
|
| 15 |
+
organizationId: z.string(),
|
| 16 |
+
organization: z.any().optional(), // Avoid circular dep for now
|
| 17 |
});
|
| 18 |
|
| 19 |
export type User = z.infer<typeof UserSchema>;
|
| 20 |
|
| 21 |
+
export const CreateUserSchema = UserSchema.pick({
|
| 22 |
+
phone: true,
|
| 23 |
+
email: true,
|
| 24 |
+
name: true,
|
| 25 |
+
language: true,
|
| 26 |
+
city: true,
|
| 27 |
+
activity: true,
|
| 28 |
+
organizationId: true
|
| 29 |
+
});
|
| 30 |
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
|
| 31 |
|
| 32 |
export const WebhookPayloadSchema = z.object({
|
packages/shared-types/src/organization.ts
CHANGED
|
@@ -21,3 +21,23 @@ export const FlowConfigSchema = z.object({
|
|
| 21 |
|
| 22 |
export type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
| 23 |
export type Sector = z.infer<typeof SectorSchema>;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
export type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
| 23 |
export type Sector = z.infer<typeof SectorSchema>;
|
| 24 |
+
|
| 25 |
+
export const OrganizationSchema = z.object({
|
| 26 |
+
name: z.string().min(1),
|
| 27 |
+
contactEmail: z.string().email().optional(),
|
| 28 |
+
customPrompt: z.string().optional(),
|
| 29 |
+
mode: z.enum(['EDTECH', 'WEBHOOK', 'AI_AGENT']).optional(),
|
| 30 |
+
flowConfig: FlowConfigSchema.optional(),
|
| 31 |
+
webhookUrl: z.string().url().optional().or(z.literal('')),
|
| 32 |
+
webhookSecret: z.string().optional(),
|
| 33 |
+
knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
|
| 34 |
+
systemUserToken: z.string().optional(),
|
| 35 |
+
brandingData: z.any().optional(),
|
| 36 |
+
contractSigned: z.boolean().optional(),
|
| 37 |
+
contractSignedAt: z.string().optional(),
|
| 38 |
+
contractSignerName: z.string().optional(),
|
| 39 |
+
metaVerificationStatus: z.string().optional(),
|
| 40 |
+
dailyMessageLimit: z.number().optional()
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
export type Organization = z.infer<typeof OrganizationSchema>;
|
pnpm-lock.yaml
CHANGED
|
@@ -100,6 +100,9 @@ importers:
|
|
| 100 |
'@fastify/cors':
|
| 101 |
specifier: ^8.0.0
|
| 102 |
version: 8.5.0
|
|
|
|
|
|
|
|
|
|
| 103 |
'@fastify/multipart':
|
| 104 |
specifier: ^10.0.0
|
| 105 |
version: 10.0.0
|
|
@@ -124,9 +127,15 @@ importers:
|
|
| 124 |
'@repo/shared-types':
|
| 125 |
specifier: workspace:*
|
| 126 |
version: link:../../packages/shared-types
|
|
|
|
|
|
|
|
|
|
| 127 |
axios:
|
| 128 |
specifier: ^1.13.5
|
| 129 |
version: 1.13.5
|
|
|
|
|
|
|
|
|
|
| 130 |
bullmq:
|
| 131 |
specifier: ^5.1.0
|
| 132 |
version: 5.69.3
|
|
@@ -1103,6 +1112,9 @@ packages:
|
|
| 1103 |
'@fastify/fast-json-stringify-compiler@4.3.0':
|
| 1104 |
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
|
| 1105 |
|
|
|
|
|
|
|
|
|
|
| 1106 |
'@fastify/merge-json-schemas@0.1.1':
|
| 1107 |
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
|
| 1108 |
|
|
@@ -1816,6 +1828,9 @@ packages:
|
|
| 1816 |
'@types/babel__traverse@7.28.0':
|
| 1817 |
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
| 1818 |
|
|
|
|
|
|
|
|
|
|
| 1819 |
'@types/chai@5.2.3':
|
| 1820 |
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
| 1821 |
|
|
@@ -2025,6 +2040,9 @@ packages:
|
|
| 2025 |
argparse@2.0.1:
|
| 2026 |
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
| 2027 |
|
|
|
|
|
|
|
|
|
|
| 2028 |
assertion-error@1.1.0:
|
| 2029 |
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
|
| 2030 |
|
|
@@ -2121,10 +2139,17 @@ packages:
|
|
| 2121 |
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
|
| 2122 |
engines: {node: '>=10.0.0'}
|
| 2123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2124 |
binary-extensions@2.3.0:
|
| 2125 |
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
| 2126 |
engines: {node: '>=8'}
|
| 2127 |
|
|
|
|
|
|
|
|
|
|
| 2128 |
boolbase@1.0.0:
|
| 2129 |
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
| 2130 |
|
|
@@ -2429,6 +2454,9 @@ packages:
|
|
| 2429 |
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
| 2430 |
engines: {node: '>= 0.4'}
|
| 2431 |
|
|
|
|
|
|
|
|
|
|
| 2432 |
ejs@5.0.2:
|
| 2433 |
resolution: {integrity: sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==}
|
| 2434 |
engines: {node: '>=0.12.18'}
|
|
@@ -2575,6 +2603,10 @@ packages:
|
|
| 2575 |
fast-json-stringify@5.16.1:
|
| 2576 |
resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
|
| 2577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2578 |
fast-levenshtein@3.0.0:
|
| 2579 |
resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==}
|
| 2580 |
|
|
@@ -2598,6 +2630,10 @@ packages:
|
|
| 2598 |
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
|
| 2599 |
engines: {node: '>= 4.9.1'}
|
| 2600 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2601 |
fastify-plugin@4.5.1:
|
| 2602 |
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
| 2603 |
|
|
@@ -2607,9 +2643,15 @@ packages:
|
|
| 2607 |
fastify@4.29.1:
|
| 2608 |
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
|
| 2609 |
|
|
|
|
|
|
|
|
|
|
| 2610 |
fastq@1.20.1:
|
| 2611 |
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
| 2612 |
|
|
|
|
|
|
|
|
|
|
| 2613 |
fd-slicer@1.1.0:
|
| 2614 |
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
| 2615 |
|
|
@@ -3034,6 +3076,9 @@ packages:
|
|
| 3034 |
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
| 3035 |
engines: {node: '>=12'}
|
| 3036 |
|
|
|
|
|
|
|
|
|
|
| 3037 |
minimatch@10.2.5:
|
| 3038 |
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
| 3039 |
engines: {node: 18 || 20 || >=22}
|
|
@@ -3058,6 +3103,9 @@ packages:
|
|
| 3058 |
mnemonist@0.39.6:
|
| 3059 |
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
|
| 3060 |
|
|
|
|
|
|
|
|
|
|
| 3061 |
motion-dom@12.38.0:
|
| 3062 |
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
| 3063 |
|
|
@@ -3096,6 +3144,10 @@ packages:
|
|
| 3096 |
node-abort-controller@3.1.1:
|
| 3097 |
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
| 3098 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3099 |
node-cron@4.2.1:
|
| 3100 |
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
| 3101 |
engines: {node: '>=6.0.0'}
|
|
@@ -3118,6 +3170,10 @@ packages:
|
|
| 3118 |
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
| 3119 |
hasBin: true
|
| 3120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3121 |
node-releases@2.0.27:
|
| 3122 |
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
| 3123 |
|
|
@@ -3528,6 +3584,10 @@ packages:
|
|
| 3528 |
resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
|
| 3529 |
engines: {node: '>=10'}
|
| 3530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3531 |
reusify@1.1.0:
|
| 3532 |
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
| 3533 |
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
|
@@ -3549,6 +3609,10 @@ packages:
|
|
| 3549 |
safe-regex2@3.1.0:
|
| 3550 |
resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
|
| 3551 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3552 |
safe-stable-stringify@2.5.0:
|
| 3553 |
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
| 3554 |
engines: {node: '>=10'}
|
|
@@ -3656,6 +3720,9 @@ packages:
|
|
| 3656 |
std-env@3.10.0:
|
| 3657 |
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
| 3658 |
|
|
|
|
|
|
|
|
|
|
| 3659 |
streamx@2.23.0:
|
| 3660 |
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
| 3661 |
|
|
@@ -4081,6 +4148,10 @@ packages:
|
|
| 4081 |
engines: {node: '>=0.8'}
|
| 4082 |
hasBin: true
|
| 4083 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4084 |
y18n@5.0.8:
|
| 4085 |
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
| 4086 |
engines: {node: '>=10'}
|
|
@@ -4983,6 +5054,14 @@ snapshots:
|
|
| 4983 |
dependencies:
|
| 4984 |
fast-json-stringify: 5.16.1
|
| 4985 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4986 |
'@fastify/merge-json-schemas@0.1.1':
|
| 4987 |
dependencies:
|
| 4988 |
fast-deep-equal: 3.1.3
|
|
@@ -5729,6 +5808,10 @@ snapshots:
|
|
| 5729 |
dependencies:
|
| 5730 |
'@babel/types': 7.29.0
|
| 5731 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5732 |
'@types/chai@5.2.3':
|
| 5733 |
dependencies:
|
| 5734 |
'@types/deep-eql': 4.0.2
|
|
@@ -5957,6 +6040,13 @@ snapshots:
|
|
| 5957 |
|
| 5958 |
argparse@2.0.1: {}
|
| 5959 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5960 |
assertion-error@1.1.0: {}
|
| 5961 |
|
| 5962 |
assertion-error@2.0.1: {}
|
|
@@ -6041,8 +6131,15 @@ snapshots:
|
|
| 6041 |
|
| 6042 |
basic-ftp@5.1.0: {}
|
| 6043 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6044 |
binary-extensions@2.3.0: {}
|
| 6045 |
|
|
|
|
|
|
|
| 6046 |
boolbase@1.0.0: {}
|
| 6047 |
|
| 6048 |
bowser@2.14.1: {}
|
|
@@ -6353,6 +6450,10 @@ snapshots:
|
|
| 6353 |
es-errors: 1.3.0
|
| 6354 |
gopd: 1.2.0
|
| 6355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6356 |
ejs@5.0.2: {}
|
| 6357 |
|
| 6358 |
electron-to-chromium@1.5.302: {}
|
|
@@ -6563,6 +6664,14 @@ snapshots:
|
|
| 6563 |
json-schema-ref-resolver: 1.0.1
|
| 6564 |
rfdc: 1.4.1
|
| 6565 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6566 |
fast-levenshtein@3.0.0:
|
| 6567 |
dependencies:
|
| 6568 |
fastest-levenshtein: 1.0.16
|
|
@@ -6583,6 +6692,10 @@ snapshots:
|
|
| 6583 |
|
| 6584 |
fastest-levenshtein@1.0.16: {}
|
| 6585 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6586 |
fastify-plugin@4.5.1: {}
|
| 6587 |
|
| 6588 |
fastify-plugin@5.1.0: {}
|
|
@@ -6606,10 +6719,20 @@ snapshots:
|
|
| 6606 |
semver: 7.7.4
|
| 6607 |
toad-cache: 3.7.0
|
| 6608 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6609 |
fastq@1.20.1:
|
| 6610 |
dependencies:
|
| 6611 |
reusify: 1.1.0
|
| 6612 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6613 |
fd-slicer@1.1.0:
|
| 6614 |
dependencies:
|
| 6615 |
pend: 1.2.0
|
|
@@ -7010,6 +7133,8 @@ snapshots:
|
|
| 7010 |
|
| 7011 |
mimic-fn@4.0.0: {}
|
| 7012 |
|
|
|
|
|
|
|
| 7013 |
minimatch@10.2.5:
|
| 7014 |
dependencies:
|
| 7015 |
brace-expansion: 5.0.5
|
|
@@ -7035,6 +7160,10 @@ snapshots:
|
|
| 7035 |
dependencies:
|
| 7036 |
obliterator: 2.0.5
|
| 7037 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7038 |
motion-dom@12.38.0:
|
| 7039 |
dependencies:
|
| 7040 |
motion-utils: 12.36.0
|
|
@@ -7077,6 +7206,8 @@ snapshots:
|
|
| 7077 |
|
| 7078 |
node-abort-controller@3.1.1: {}
|
| 7079 |
|
|
|
|
|
|
|
| 7080 |
node-cron@4.2.1: {}
|
| 7081 |
|
| 7082 |
node-domexception@1.0.0: {}
|
|
@@ -7090,6 +7221,8 @@ snapshots:
|
|
| 7090 |
detect-libc: 2.1.2
|
| 7091 |
optional: true
|
| 7092 |
|
|
|
|
|
|
|
| 7093 |
node-releases@2.0.27: {}
|
| 7094 |
|
| 7095 |
normalize-path@3.0.0: {}
|
|
@@ -7544,6 +7677,8 @@ snapshots:
|
|
| 7544 |
|
| 7545 |
ret@0.4.3: {}
|
| 7546 |
|
|
|
|
|
|
|
| 7547 |
reusify@1.1.0: {}
|
| 7548 |
|
| 7549 |
rfdc@1.4.1: {}
|
|
@@ -7589,6 +7724,10 @@ snapshots:
|
|
| 7589 |
dependencies:
|
| 7590 |
ret: 0.4.3
|
| 7591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7592 |
safe-stable-stringify@2.5.0: {}
|
| 7593 |
|
| 7594 |
safer-buffer@2.1.2: {}
|
|
@@ -7704,6 +7843,14 @@ snapshots:
|
|
| 7704 |
|
| 7705 |
std-env@3.10.0: {}
|
| 7706 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7707 |
streamx@2.23.0:
|
| 7708 |
dependencies:
|
| 7709 |
events-universal: 1.0.1
|
|
@@ -8130,6 +8277,8 @@ snapshots:
|
|
| 8130 |
wmf: 1.0.2
|
| 8131 |
word: 0.3.0
|
| 8132 |
|
|
|
|
|
|
|
| 8133 |
y18n@5.0.8: {}
|
| 8134 |
|
| 8135 |
yallist@3.1.1: {}
|
|
|
|
| 100 |
'@fastify/cors':
|
| 101 |
specifier: ^8.0.0
|
| 102 |
version: 8.5.0
|
| 103 |
+
'@fastify/jwt':
|
| 104 |
+
specifier: ^10.0.0
|
| 105 |
+
version: 10.0.0
|
| 106 |
'@fastify/multipart':
|
| 107 |
specifier: ^10.0.0
|
| 108 |
version: 10.0.0
|
|
|
|
| 127 |
'@repo/shared-types':
|
| 128 |
specifier: workspace:*
|
| 129 |
version: link:../../packages/shared-types
|
| 130 |
+
'@types/bcrypt':
|
| 131 |
+
specifier: ^6.0.0
|
| 132 |
+
version: 6.0.0
|
| 133 |
axios:
|
| 134 |
specifier: ^1.13.5
|
| 135 |
version: 1.13.5
|
| 136 |
+
bcrypt:
|
| 137 |
+
specifier: ^6.0.0
|
| 138 |
+
version: 6.0.0
|
| 139 |
bullmq:
|
| 140 |
specifier: ^5.1.0
|
| 141 |
version: 5.69.3
|
|
|
|
| 1112 |
'@fastify/fast-json-stringify-compiler@4.3.0':
|
| 1113 |
resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==}
|
| 1114 |
|
| 1115 |
+
'@fastify/jwt@10.0.0':
|
| 1116 |
+
resolution: {integrity: sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==}
|
| 1117 |
+
|
| 1118 |
'@fastify/merge-json-schemas@0.1.1':
|
| 1119 |
resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
|
| 1120 |
|
|
|
|
| 1828 |
'@types/babel__traverse@7.28.0':
|
| 1829 |
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
| 1830 |
|
| 1831 |
+
'@types/bcrypt@6.0.0':
|
| 1832 |
+
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
|
| 1833 |
+
|
| 1834 |
'@types/chai@5.2.3':
|
| 1835 |
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
| 1836 |
|
|
|
|
| 2040 |
argparse@2.0.1:
|
| 2041 |
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
| 2042 |
|
| 2043 |
+
asn1.js@5.4.1:
|
| 2044 |
+
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
| 2045 |
+
|
| 2046 |
assertion-error@1.1.0:
|
| 2047 |
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
|
| 2048 |
|
|
|
|
| 2139 |
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
|
| 2140 |
engines: {node: '>=10.0.0'}
|
| 2141 |
|
| 2142 |
+
bcrypt@6.0.0:
|
| 2143 |
+
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
| 2144 |
+
engines: {node: '>= 18'}
|
| 2145 |
+
|
| 2146 |
binary-extensions@2.3.0:
|
| 2147 |
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
| 2148 |
engines: {node: '>=8'}
|
| 2149 |
|
| 2150 |
+
bn.js@4.12.3:
|
| 2151 |
+
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
| 2152 |
+
|
| 2153 |
boolbase@1.0.0:
|
| 2154 |
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
| 2155 |
|
|
|
|
| 2454 |
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
| 2455 |
engines: {node: '>= 0.4'}
|
| 2456 |
|
| 2457 |
+
ecdsa-sig-formatter@1.0.11:
|
| 2458 |
+
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
| 2459 |
+
|
| 2460 |
ejs@5.0.2:
|
| 2461 |
resolution: {integrity: sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==}
|
| 2462 |
engines: {node: '>=0.12.18'}
|
|
|
|
| 2603 |
fast-json-stringify@5.16.1:
|
| 2604 |
resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==}
|
| 2605 |
|
| 2606 |
+
fast-jwt@6.2.2:
|
| 2607 |
+
resolution: {integrity: sha512-lzy+8JVyBOvwxjydFRBKLFVe1elRArL37pHRX1zHPt4T7FP7kNIpqauE1lOjZlD79DBzzRzQmp+28wbsY13weA==}
|
| 2608 |
+
engines: {node: '>=20'}
|
| 2609 |
+
|
| 2610 |
fast-levenshtein@3.0.0:
|
| 2611 |
resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==}
|
| 2612 |
|
|
|
|
| 2630 |
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
|
| 2631 |
engines: {node: '>= 4.9.1'}
|
| 2632 |
|
| 2633 |
+
fastfall@1.5.1:
|
| 2634 |
+
resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==}
|
| 2635 |
+
engines: {node: '>=0.10.0'}
|
| 2636 |
+
|
| 2637 |
fastify-plugin@4.5.1:
|
| 2638 |
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
| 2639 |
|
|
|
|
| 2643 |
fastify@4.29.1:
|
| 2644 |
resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==}
|
| 2645 |
|
| 2646 |
+
fastparallel@2.4.1:
|
| 2647 |
+
resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==}
|
| 2648 |
+
|
| 2649 |
fastq@1.20.1:
|
| 2650 |
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
| 2651 |
|
| 2652 |
+
fastseries@1.7.2:
|
| 2653 |
+
resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==}
|
| 2654 |
+
|
| 2655 |
fd-slicer@1.1.0:
|
| 2656 |
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
| 2657 |
|
|
|
|
| 3076 |
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
|
| 3077 |
engines: {node: '>=12'}
|
| 3078 |
|
| 3079 |
+
minimalistic-assert@1.0.1:
|
| 3080 |
+
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
| 3081 |
+
|
| 3082 |
minimatch@10.2.5:
|
| 3083 |
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
|
| 3084 |
engines: {node: 18 || 20 || >=22}
|
|
|
|
| 3103 |
mnemonist@0.39.6:
|
| 3104 |
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
|
| 3105 |
|
| 3106 |
+
mnemonist@0.40.3:
|
| 3107 |
+
resolution: {integrity: sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==}
|
| 3108 |
+
|
| 3109 |
motion-dom@12.38.0:
|
| 3110 |
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
| 3111 |
|
|
|
|
| 3144 |
node-abort-controller@3.1.1:
|
| 3145 |
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
|
| 3146 |
|
| 3147 |
+
node-addon-api@8.7.0:
|
| 3148 |
+
resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==}
|
| 3149 |
+
engines: {node: ^18 || ^20 || >= 21}
|
| 3150 |
+
|
| 3151 |
node-cron@4.2.1:
|
| 3152 |
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
| 3153 |
engines: {node: '>=6.0.0'}
|
|
|
|
| 3170 |
resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
|
| 3171 |
hasBin: true
|
| 3172 |
|
| 3173 |
+
node-gyp-build@4.8.4:
|
| 3174 |
+
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
| 3175 |
+
hasBin: true
|
| 3176 |
+
|
| 3177 |
node-releases@2.0.27:
|
| 3178 |
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
| 3179 |
|
|
|
|
| 3584 |
resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==}
|
| 3585 |
engines: {node: '>=10'}
|
| 3586 |
|
| 3587 |
+
ret@0.5.0:
|
| 3588 |
+
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
| 3589 |
+
engines: {node: '>=10'}
|
| 3590 |
+
|
| 3591 |
reusify@1.1.0:
|
| 3592 |
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
| 3593 |
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
|
|
|
| 3609 |
safe-regex2@3.1.0:
|
| 3610 |
resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==}
|
| 3611 |
|
| 3612 |
+
safe-regex2@5.1.1:
|
| 3613 |
+
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
|
| 3614 |
+
hasBin: true
|
| 3615 |
+
|
| 3616 |
safe-stable-stringify@2.5.0:
|
| 3617 |
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
| 3618 |
engines: {node: '>=10'}
|
|
|
|
| 3720 |
std-env@3.10.0:
|
| 3721 |
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
| 3722 |
|
| 3723 |
+
steed@1.1.3:
|
| 3724 |
+
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
| 3725 |
+
|
| 3726 |
streamx@2.23.0:
|
| 3727 |
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
| 3728 |
|
|
|
|
| 4148 |
engines: {node: '>=0.8'}
|
| 4149 |
hasBin: true
|
| 4150 |
|
| 4151 |
+
xtend@4.0.2:
|
| 4152 |
+
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
| 4153 |
+
engines: {node: '>=0.4'}
|
| 4154 |
+
|
| 4155 |
y18n@5.0.8:
|
| 4156 |
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
| 4157 |
engines: {node: '>=10'}
|
|
|
|
| 5054 |
dependencies:
|
| 5055 |
fast-json-stringify: 5.16.1
|
| 5056 |
|
| 5057 |
+
'@fastify/jwt@10.0.0':
|
| 5058 |
+
dependencies:
|
| 5059 |
+
'@fastify/error': 4.2.0
|
| 5060 |
+
'@lukeed/ms': 2.0.2
|
| 5061 |
+
fast-jwt: 6.2.2
|
| 5062 |
+
fastify-plugin: 5.1.0
|
| 5063 |
+
steed: 1.1.3
|
| 5064 |
+
|
| 5065 |
'@fastify/merge-json-schemas@0.1.1':
|
| 5066 |
dependencies:
|
| 5067 |
fast-deep-equal: 3.1.3
|
|
|
|
| 5808 |
dependencies:
|
| 5809 |
'@babel/types': 7.29.0
|
| 5810 |
|
| 5811 |
+
'@types/bcrypt@6.0.0':
|
| 5812 |
+
dependencies:
|
| 5813 |
+
'@types/node': 20.19.33
|
| 5814 |
+
|
| 5815 |
'@types/chai@5.2.3':
|
| 5816 |
dependencies:
|
| 5817 |
'@types/deep-eql': 4.0.2
|
|
|
|
| 6040 |
|
| 6041 |
argparse@2.0.1: {}
|
| 6042 |
|
| 6043 |
+
asn1.js@5.4.1:
|
| 6044 |
+
dependencies:
|
| 6045 |
+
bn.js: 4.12.3
|
| 6046 |
+
inherits: 2.0.4
|
| 6047 |
+
minimalistic-assert: 1.0.1
|
| 6048 |
+
safer-buffer: 2.1.2
|
| 6049 |
+
|
| 6050 |
assertion-error@1.1.0: {}
|
| 6051 |
|
| 6052 |
assertion-error@2.0.1: {}
|
|
|
|
| 6131 |
|
| 6132 |
basic-ftp@5.1.0: {}
|
| 6133 |
|
| 6134 |
+
bcrypt@6.0.0:
|
| 6135 |
+
dependencies:
|
| 6136 |
+
node-addon-api: 8.7.0
|
| 6137 |
+
node-gyp-build: 4.8.4
|
| 6138 |
+
|
| 6139 |
binary-extensions@2.3.0: {}
|
| 6140 |
|
| 6141 |
+
bn.js@4.12.3: {}
|
| 6142 |
+
|
| 6143 |
boolbase@1.0.0: {}
|
| 6144 |
|
| 6145 |
bowser@2.14.1: {}
|
|
|
|
| 6450 |
es-errors: 1.3.0
|
| 6451 |
gopd: 1.2.0
|
| 6452 |
|
| 6453 |
+
ecdsa-sig-formatter@1.0.11:
|
| 6454 |
+
dependencies:
|
| 6455 |
+
safe-buffer: 5.1.2
|
| 6456 |
+
|
| 6457 |
ejs@5.0.2: {}
|
| 6458 |
|
| 6459 |
electron-to-chromium@1.5.302: {}
|
|
|
|
| 6664 |
json-schema-ref-resolver: 1.0.1
|
| 6665 |
rfdc: 1.4.1
|
| 6666 |
|
| 6667 |
+
fast-jwt@6.2.2:
|
| 6668 |
+
dependencies:
|
| 6669 |
+
'@lukeed/ms': 2.0.2
|
| 6670 |
+
asn1.js: 5.4.1
|
| 6671 |
+
ecdsa-sig-formatter: 1.0.11
|
| 6672 |
+
mnemonist: 0.40.3
|
| 6673 |
+
safe-regex2: 5.1.1
|
| 6674 |
+
|
| 6675 |
fast-levenshtein@3.0.0:
|
| 6676 |
dependencies:
|
| 6677 |
fastest-levenshtein: 1.0.16
|
|
|
|
| 6692 |
|
| 6693 |
fastest-levenshtein@1.0.16: {}
|
| 6694 |
|
| 6695 |
+
fastfall@1.5.1:
|
| 6696 |
+
dependencies:
|
| 6697 |
+
reusify: 1.1.0
|
| 6698 |
+
|
| 6699 |
fastify-plugin@4.5.1: {}
|
| 6700 |
|
| 6701 |
fastify-plugin@5.1.0: {}
|
|
|
|
| 6719 |
semver: 7.7.4
|
| 6720 |
toad-cache: 3.7.0
|
| 6721 |
|
| 6722 |
+
fastparallel@2.4.1:
|
| 6723 |
+
dependencies:
|
| 6724 |
+
reusify: 1.1.0
|
| 6725 |
+
xtend: 4.0.2
|
| 6726 |
+
|
| 6727 |
fastq@1.20.1:
|
| 6728 |
dependencies:
|
| 6729 |
reusify: 1.1.0
|
| 6730 |
|
| 6731 |
+
fastseries@1.7.2:
|
| 6732 |
+
dependencies:
|
| 6733 |
+
reusify: 1.1.0
|
| 6734 |
+
xtend: 4.0.2
|
| 6735 |
+
|
| 6736 |
fd-slicer@1.1.0:
|
| 6737 |
dependencies:
|
| 6738 |
pend: 1.2.0
|
|
|
|
| 7133 |
|
| 7134 |
mimic-fn@4.0.0: {}
|
| 7135 |
|
| 7136 |
+
minimalistic-assert@1.0.1: {}
|
| 7137 |
+
|
| 7138 |
minimatch@10.2.5:
|
| 7139 |
dependencies:
|
| 7140 |
brace-expansion: 5.0.5
|
|
|
|
| 7160 |
dependencies:
|
| 7161 |
obliterator: 2.0.5
|
| 7162 |
|
| 7163 |
+
mnemonist@0.40.3:
|
| 7164 |
+
dependencies:
|
| 7165 |
+
obliterator: 2.0.5
|
| 7166 |
+
|
| 7167 |
motion-dom@12.38.0:
|
| 7168 |
dependencies:
|
| 7169 |
motion-utils: 12.36.0
|
|
|
|
| 7206 |
|
| 7207 |
node-abort-controller@3.1.1: {}
|
| 7208 |
|
| 7209 |
+
node-addon-api@8.7.0: {}
|
| 7210 |
+
|
| 7211 |
node-cron@4.2.1: {}
|
| 7212 |
|
| 7213 |
node-domexception@1.0.0: {}
|
|
|
|
| 7221 |
detect-libc: 2.1.2
|
| 7222 |
optional: true
|
| 7223 |
|
| 7224 |
+
node-gyp-build@4.8.4: {}
|
| 7225 |
+
|
| 7226 |
node-releases@2.0.27: {}
|
| 7227 |
|
| 7228 |
normalize-path@3.0.0: {}
|
|
|
|
| 7677 |
|
| 7678 |
ret@0.4.3: {}
|
| 7679 |
|
| 7680 |
+
ret@0.5.0: {}
|
| 7681 |
+
|
| 7682 |
reusify@1.1.0: {}
|
| 7683 |
|
| 7684 |
rfdc@1.4.1: {}
|
|
|
|
| 7724 |
dependencies:
|
| 7725 |
ret: 0.4.3
|
| 7726 |
|
| 7727 |
+
safe-regex2@5.1.1:
|
| 7728 |
+
dependencies:
|
| 7729 |
+
ret: 0.5.0
|
| 7730 |
+
|
| 7731 |
safe-stable-stringify@2.5.0: {}
|
| 7732 |
|
| 7733 |
safer-buffer@2.1.2: {}
|
|
|
|
| 7843 |
|
| 7844 |
std-env@3.10.0: {}
|
| 7845 |
|
| 7846 |
+
steed@1.1.3:
|
| 7847 |
+
dependencies:
|
| 7848 |
+
fastfall: 1.5.1
|
| 7849 |
+
fastparallel: 2.4.1
|
| 7850 |
+
fastq: 1.20.1
|
| 7851 |
+
fastseries: 1.7.2
|
| 7852 |
+
reusify: 1.1.0
|
| 7853 |
+
|
| 7854 |
streamx@2.23.0:
|
| 7855 |
dependencies:
|
| 7856 |
events-universal: 1.0.1
|
|
|
|
| 8277 |
wmf: 1.0.2
|
| 8278 |
word: 0.3.0
|
| 8279 |
|
| 8280 |
+
xtend@4.0.2: {}
|
| 8281 |
+
|
| 8282 |
y18n@5.0.8: {}
|
| 8283 |
|
| 8284 |
yallist@3.1.1: {}
|