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 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 { apiKey } = useAuth();
26
- if (!apiKey) return <Navigate to="/login" replace />;
27
  return <>{children}</>;
28
  }
29
 
30
  function AppShell() {
31
- const { logout, apiKey } = useAuth();
32
- const { selectedOrgId, setSelectedOrgId } = useTenant();
33
  const [orgs, setOrgs] = React.useState<any[]>([]);
34
 
 
 
35
  React.useEffect(() => {
36
- if (!apiKey) return;
37
  fetch(`${API_URL}/v1/organizations`, {
38
- headers: { 'Authorization': `Bearer ${apiKey}` }
39
  }).then(r => r.json()).then(setOrgs).catch(console.error);
40
- }, [apiKey]);
 
 
 
 
 
 
 
41
 
42
- const navItems = [
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 bg-slate-900 text-white p-5 flex flex-col shrink-0">
56
- <div className="text-lg font-bold mb-4 flex items-center gap-2"><span className="text-2xl">🎓</span>EdTech Admin</div>
57
-
58
- <div className="mb-8">
59
- <label className="block text-[10px] uppercase font-bold text-slate-500 tracking-wider mb-2">Organisation Active</label>
60
- <select
61
- value={selectedOrgId || ''}
62
- onChange={e => setSelectedOrgId(e.target.value)}
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 key={n.to} to={n.to} className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition">
 
 
 
 
75
  {n.icon}{n.label}
76
  </Link>
77
  ))}
78
  </nav>
79
- <button onClick={logout} className="text-xs text-slate-500 hover:text-white transition px-3 py-2 text-left">🔓 Se déconnecter</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = (k: string) => ({
4
- 'Authorization': `Bearer ${k}`,
5
- 'Content-Type': 'application/json'
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 = 'edtech_admin_key';
 
4
 
5
- export const AuthContext = createContext<{ apiKey: string | null; login: (k: string) => void; logout: () => void; }>({
6
- apiKey: null,
 
 
 
 
 
 
 
 
7
  login: () => {},
8
  logout: () => {}
9
  });
10
 
11
  export function AuthProvider({ children }: { children: React.ReactNode }) {
12
- const [apiKey, setApiKey] = useState<string | null>(() => sessionStorage.getItem(SESSION_KEY));
 
 
 
 
13
 
14
- const login = (k: string) => {
15
- sessionStorage.setItem(SESSION_KEY, k);
16
- setApiKey(k);
 
 
17
  };
18
 
19
  const logout = () => {
20
  sessionStorage.removeItem(SESSION_KEY);
21
- setApiKey(null);
 
 
22
  };
23
 
24
  return (
25
- <AuthContext.Provider value={{ apiKey, login, logout }}>
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 { apiKey } = 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 || !apiKey) return;
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
- headers: {
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, apiKey]);
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 { apiKey } = useAuth();
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(apiKey || '')
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
- }, [apiKey]);
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(apiKey || ''),
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(apiKey || ''),
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(apiKey || ''),
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 { apiKey, logout } = useAuth();
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
- }, [apiKey, logout, selectedOrgId]);
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 { apiKey } = useAuth();
32
- // MOCK ADMIN ID (Replace with actual auth context)
33
- const ADMIN_ID = "admin_01";
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(getApiUrl('/live-feed'), {
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)} // Temporarily hide it the same way
123
- getApiUrl={getApiUrl}
124
- apiKey={apiKey || ''}
125
  />
126
  ))
127
  )}
@@ -134,7 +128,7 @@ export default function LiveFeed() {
134
  // MODERATION CARD COMPONENT
135
  // ----------------------------------------------------------------------
136
 
137
- function ModerationCard({ review, adminId, onSuccess, onSkip, getApiUrl, apiKey }: { key?: string | number, review: PendingReview, adminId: string, onSuccess: () => void, onSkip: () => void, getApiUrl: (path: string) => string, apiKey: string }) {
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 } as Record<number, number>;
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(getApiUrl('/upload'), {
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
- // 2. Submit the Override Feedback
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: any) => setTranscription(e.target.value)}
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<BlobPart[]>([]);
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: any) => {
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
- // Stop all tracks to release mic
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, apiKey } = useAuth();
 
8
  const navigate = useNavigate();
9
- const [key, setKey] = useState('');
 
 
10
  const [error, setError] = useState('');
11
  const [loading, setLoading] = useState(false);
12
 
13
  useEffect(() => {
14
- if (apiKey) navigate('/', { replace: true });
15
- }, [apiKey, navigate]);
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/admin/stats`, {
23
- headers: { 'Authorization': `Bearer ${key}` }
 
 
24
  });
 
 
 
25
  if (res.ok) {
26
- login(key);
27
  navigate('/', { replace: true });
28
  } else {
29
- setError('Clé API invalide.');
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 bg-slate-900 flex items-center justify-center p-4">
40
- <div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm">
41
- <div className="text-center mb-6">
42
- <div className="text-3xl mb-2">🔐</div>
43
- <h1 className="text-2xl font-bold text-slate-800">Admin Access</h1>
44
- <p className="text-sm text-slate-500 mt-1">Entrez votre ADMIN_API_KEY</p>
 
 
 
 
 
 
 
 
45
  </div>
46
- <form onSubmit={handleSubmit} className="space-y-4">
47
- <input id="apiKey" type="password" required placeholder="sk-admin-..." value={key}
48
- onChange={e => setKey(e.target.value)}
49
- className="w-full border border-slate-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-slate-400" />
50
- {error && <p className="text-red-500 text-sm">{error}</p>}
51
- <button type="submit" disabled={loading}
52
- className="w-full bg-slate-900 hover:bg-slate-700 text-white py-3 rounded-xl font-bold text-sm transition disabled:opacity-50">
53
- {loading ? 'Vérification...' : 'Se connecter'}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { apiKey } = useAuth();
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(apiKey!) }),
28
- fetch(`${API_URL}/v1/admin/tracks/${trackId}/days`, { headers: ah(apiKey!) })
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(apiKey!), body: JSON.stringify(editing) });
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(apiKey!) });
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 { apiKey } = useAuth();
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(apiKey!) })
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, apiKey, isNew]);
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(apiKey!),
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 { apiKey } = useAuth();
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
- const r = await fetch(`${API_URL}/v1/admin/tracks`, { headers: ah(apiKey!) });
 
23
  setTracks(await r.json());
24
  setLoading(false);
25
  };
26
 
27
  useEffect(() => {
28
- if (selectedOrgId || !apiKey) {
29
  load();
30
  }
31
- }, [selectedOrgId, apiKey]);
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(apiKey!) });
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 { apiKey, logout } = useAuth();
 
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: { 'Authorization': `Bearer ${apiKey}` }
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, apiKey, logout]);
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: { 'Authorization': `Bearer ${apiKey}` }
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: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
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: { 'Authorization': `Bearer ${apiKey}` }
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 { apiKey } = useAuth();
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
- const ah = (k: string) => ({
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(apiKey!) })
30
  .then(r => r.json())
31
  .then(d => {
32
  setUsers(d.users || d);
33
  setTotal(d.total || 0);
34
  setLoading(false);
35
  });
36
- }, [apiKey, selectedOrgId]);
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(apiKey!) });
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 (require ADMIN_API_KEY) ─────────────────────────────────────
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
- if (token !== apiKey) {
119
- return reply.code(401).send({ error: 'Unauthorized', message: 'Invalid API key' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
- // 🏢 Multi-Tenant Context Injection
123
- const orgId = request.headers['x-organization-id'] as string;
124
 
125
- // Routes that REQUIRE an organization context
 
 
 
 
 
 
 
 
 
 
126
  const isOrgIndependentRoute = request.url.startsWith('/v1/organizations') || request.url.startsWith('/v1/internal');
127
-
128
- if (!orgId && !isOrgIndependentRoute) {
129
- return reply.code(400).send({ error: 'Bad Request', message: 'Missing x-organization-id header' });
130
  }
131
 
132
- if (orgId) {
133
- request.log.info(`[CONTEXT] Setting Organization Context: ${orgId}`);
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 { invalidateOrganizationCache } from '../services/organization';
6
- import { FlowConfigSchema, encrypt, decrypt } from '@repo/shared-types';
 
 
 
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 OrganizationSchema = z.object({
27
- name: z.string().min(1),
28
- contactEmail: z.string().email().optional(),
29
- customPrompt: z.string().optional(),
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 = OrganizationSchema.safeParse(req.body);
81
  if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
82
 
83
- const data = encryptSecrets(body.data);
84
- const org = await prisma.organization.create({
85
- data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.findUnique({ where: { phone } });
 
 
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 String @id @default(uuid())
12
- name String
13
- wabaId String? @unique
14
- systemUserToken String?
15
- mode OrganizationMode @default(EDTECH)
16
- flowConfig Json? // Dynamic menus and routing for EDTECH mode
17
- webhookUrl String? // Destination for WEBHOOK mode
18
- webhookSecret String? // Secret for WEBHOOK mode
19
- knowledgeBaseUrl String? // PDF/Doc link for AI_AGENT mode
20
- customPrompt String? @db.Text
21
- personalityConfig Json? // Dynamic personality variables
22
- brandingData Json?
23
- stripeCustomerId String?
24
- subscriptionStatus String? @default("ACTIVE") // PENDING, ACTIVE, CANCELED, PAST_DUE
25
- createdAt DateTime @default(now())
26
- updatedAt DateTime @updatedAt
27
- users User[]
28
- tracks Track[]
29
- phoneNumbers WhatsAppPhoneNumber[]
30
- enrollments Enrollment[]
31
- messages Message[]
32
- payments Payment[]
33
- responses Response[]
34
- progress UserProgress[]
35
- trackDays TrackDay[]
36
- businessProfiles BusinessProfile[]
37
- teamMembers TeamMember[]
38
- userBadges UserBadge[]
39
- kbEntries KnowledgeBaseEntry[]
 
40
  }
41
 
42
  model KnowledgeBaseEntry {
43
- id String @id @default(uuid())
44
  organizationId String
45
- content String @db.Text
46
- embedding Unsupported("vector(1536)")? // 1536 for OpenAI embeddings
47
  metadata Json?
48
- organization Organization @relation(fields: [organizationId], references: [id])
49
- createdAt DateTime @default(now())
50
 
51
  @@index([organizationId])
52
  }
53
 
54
  model WhatsAppPhoneNumber {
55
- id String @id // Meta phone_number_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 @unique
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
- teamMembersList TeamMember[]
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
- organization Organization @relation(fields: [organizationId], references: [id])
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
- track Track @relation(fields: [trackId], references: [id])
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({ phone: true, name: true, language: true, city: true, activity: true });
 
 
 
 
 
 
 
 
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: {}