CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
bd9eb5b
·
1 Parent(s): c12937b

feat(meta): live Meta verification status + WhatsApp setup fixes

Browse files

- GET /v1/organizations/:id/meta-status — fetches WABA account_review_status
and business verification_status live from Meta Graph API, cached 1h in Redis
- POST /v1/organizations/:id/meta-status/refresh — force-invalidates the cache
- ClientsManagementView: replaced static metaVerificationStatus field with live
per-org Meta status (WABA badge, Business badge, derived daily limit)
- launchEmbeddedSignup: fixed race condition — now waits for both the OAuth code
(FB.login) and WABA/phone IDs (WA_EMBEDDED_SIGNUP_EVENT) before resolving
- /whatsapp-setup: exchanges OAuth code for long-lived Meta token (60 days)
before storing; bypasses exchange if token already starts with EAA
- OnboardingWizard: removed broken WhatsApp step (3 steps: welcome, legal, AI)
- OrganizationSchema: removed non-existent DB fields (contractSigned,
contractSignedAt, contractSignerName, metaVerificationStatus, dailyMessageLimit)
that caused prisma.organization.create() to throw Unknown arg at runtime

Requires new env vars: META_APP_ID, WHATSAPP_APP_SECRET (on HF Space + Railway)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/components/WhatsAppConnectButton.tsx CHANGED
@@ -23,10 +23,10 @@ export const WhatsAppConnectButton: React.FC = () => {
23
  // 1. Launch Meta Popup
24
  const result = await launchEmbeddedSignup();
25
 
26
- // 2. Send IDs and Code/Token to our Backend
27
  await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, {
28
  wabaId: result.waba_id,
29
- accessToken: result.accessToken || result.code,
30
  phoneNumberId: result.phone_number_id,
31
  phoneNumber: ''
32
  }, token, orgId);
 
23
  // 1. Launch Meta Popup
24
  const result = await launchEmbeddedSignup();
25
 
26
+ // 2. Send OAuth code + WABA/phone IDs to backend for token exchange
27
  await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, {
28
  wabaId: result.waba_id,
29
+ accessToken: result.code,
30
  phoneNumberId: result.phone_number_id,
31
  phoneNumber: ''
32
  }, token, orgId);
apps/admin/src/lib/meta-signup.ts CHANGED
@@ -18,14 +18,13 @@ export const initMetaSDK = () => {
18
  }
19
 
20
  const initOptions = {
21
- appId : META_APP_ID,
22
- cookie : true,
23
- xfbml : true,
24
- version : 'v19.0'
25
  };
26
 
27
  if (window.FB) {
28
- console.log("[META-SDK] SDK already loaded, initializing directly.");
29
  window.FB.init(initOptions);
30
  } else {
31
  window.fbAsyncInit = function() {
@@ -34,33 +33,48 @@ export const initMetaSDK = () => {
34
  }
35
  };
36
 
37
- export interface MetaSignupResponse {
38
- accessToken: string;
39
- wabaId: string;
 
40
  }
41
 
42
- export const launchEmbeddedSignup = (): Promise<any> => {
43
  return new Promise((resolve, reject) => {
44
  if (!window.FB) {
45
  return reject(new Error("Le SDK Meta n'est pas chargé."));
46
  }
47
 
48
- // Listener to capture IDs sent by Meta during onboarding
49
- const sessionHandler = (event: any) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  if (event.origin !== "https://www.facebook.com") return;
51
-
52
  try {
53
- const data = JSON.parse(event.data);
54
  if (data.type === 'WA_EMBEDDED_SIGNUP_EVENT') {
55
  if (data.event === 'FINISH') {
56
- const { phone_number_id, waba_id } = data.data;
57
- console.log("[META-SDK] Onboarding terminé", { waba_id, phone_number_id });
58
- resolve({ waba_id, phone_number_id, success: true });
59
- } else if (data.event === 'CANCEL') {
 
 
60
  reject(new Error("L'utilisateur a annulé l'onboarding."));
61
  }
62
  }
63
- } catch (err) {
64
  // Not our event
65
  }
66
  };
@@ -68,21 +82,20 @@ export const launchEmbeddedSignup = (): Promise<any> => {
68
  window.addEventListener('message', sessionHandler);
69
 
70
  window.FB.login((response: any) => {
71
- if (response.authResponse) {
72
- const code = response.authResponse.code;
73
- resolve({ code });
74
- } else {
 
75
  window.removeEventListener('message', sessionHandler);
76
  reject(new Error("Connexion Facebook échouée ou annulée."));
77
  }
78
  }, {
79
- config_id: import.meta.env.VITE_META_CONFIG_ID || '', // Requested config_id
80
- response_type: 'code', // Requested response_type
81
- override_default_response_type: true, // Requested override
82
  scope: 'whatsapp_business_management,whatsapp_business_messaging',
83
- extras: {
84
- feature: 'whatsapp_embedded_signup'
85
- }
86
  });
87
  });
88
  };
 
18
  }
19
 
20
  const initOptions = {
21
+ appId : META_APP_ID,
22
+ cookie : true,
23
+ xfbml : true,
24
+ version: 'v19.0'
25
  };
26
 
27
  if (window.FB) {
 
28
  window.FB.init(initOptions);
29
  } else {
30
  window.fbAsyncInit = function() {
 
33
  }
34
  };
35
 
36
+ export interface MetaSignupResult {
37
+ code: string;
38
+ waba_id: string;
39
+ phone_number_id: string;
40
  }
41
 
42
+ export const launchEmbeddedSignup = (): Promise<MetaSignupResult> => {
43
  return new Promise((resolve, reject) => {
44
  if (!window.FB) {
45
  return reject(new Error("Le SDK Meta n'est pas chargé."));
46
  }
47
 
48
+ // Collect both the OAuth code (from FB.login) and the WABA/phone IDs
49
+ // (from WA_EMBEDDED_SIGNUP_EVENT) before resolving — they arrive independently.
50
+ const collected: Partial<MetaSignupResult> = {};
51
+ let settled = false;
52
+
53
+ const tryResolve = () => {
54
+ if (settled) return;
55
+ if (collected.code && collected.waba_id && collected.phone_number_id) {
56
+ settled = true;
57
+ window.removeEventListener('message', sessionHandler);
58
+ resolve(collected as MetaSignupResult);
59
+ }
60
+ };
61
+
62
+ const sessionHandler = (event: MessageEvent) => {
63
  if (event.origin !== "https://www.facebook.com") return;
 
64
  try {
65
+ const data = JSON.parse(event.data as string);
66
  if (data.type === 'WA_EMBEDDED_SIGNUP_EVENT') {
67
  if (data.event === 'FINISH') {
68
+ collected.waba_id = data.data.waba_id;
69
+ collected.phone_number_id = data.data.phone_number_id;
70
+ tryResolve();
71
+ } else if (data.event === 'CANCEL' && !settled) {
72
+ settled = true;
73
+ window.removeEventListener('message', sessionHandler);
74
  reject(new Error("L'utilisateur a annulé l'onboarding."));
75
  }
76
  }
77
+ } catch {
78
  // Not our event
79
  }
80
  };
 
82
  window.addEventListener('message', sessionHandler);
83
 
84
  window.FB.login((response: any) => {
85
+ if (response.authResponse?.code) {
86
+ collected.code = response.authResponse.code;
87
+ tryResolve();
88
+ } else if (!settled) {
89
+ settled = true;
90
  window.removeEventListener('message', sessionHandler);
91
  reject(new Error("Connexion Facebook échouée ou annulée."));
92
  }
93
  }, {
94
+ config_id: import.meta.env.VITE_META_CONFIG_ID || '',
95
+ response_type: 'code',
96
+ override_default_response_type: true,
97
  scope: 'whatsapp_business_management,whatsapp_business_messaging',
98
+ extras: { feature: 'whatsapp_embedded_signup' }
 
 
99
  });
100
  });
101
  };
apps/admin/src/pages/ClientsManagementView.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState, useEffect } from 'react';
2
- import { Building2, Plus, MessageSquare, ShieldCheck, Activity, Loader2, X } from 'lucide-react';
3
  import { api } from '../lib/api';
4
  import { useAuth } from '../lib/auth';
5
  import { launchEmbeddedSignup } from '../lib/meta-signup';
@@ -9,17 +9,9 @@ import { useToast } from '../hooks/useToast';
9
  interface Organization {
10
  id: string;
11
  name: string;
12
- status: 'ACTIVE' | 'PENDING' | 'CONFIG_REQUIRED';
13
  mode: 'EDTECH' | 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE';
14
- useCase: 'EDUCATION' | 'CRM_WHATSAPP';
15
  wabaId?: string;
16
- phoneNumbers?: { id: string, phoneNumber: string }[];
17
- lastActivity?: string;
18
- metaVerificationStatus: 'PENDING' | 'VERIFIED' | 'REJECTED';
19
- dailyMessageLimit: number;
20
- contractSigned: boolean;
21
- contractSignedAt?: string;
22
- contractSignerName?: string;
23
  personalityConfig?: {
24
  botName?: string;
25
  coreMission?: string;
@@ -27,6 +19,17 @@ interface Organization {
27
  };
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
30
  interface PersonalityModalProps {
31
  org: Organization;
32
  onClose: () => void;
@@ -52,19 +55,34 @@ export default function ClientsManagementView() {
52
  const [isCreating, setIsCreating] = useState(false);
53
  const [showGuide, setShowGuide] = useState(false);
54
  const [billingOrg, setBillingOrg] = useState<Organization | null>(null);
55
-
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const fetchClients = async () => {
57
  if (!token) return;
58
  try {
59
  const data = await api.get('/v1/organizations', token);
60
  setClients(data);
 
 
61
  } catch (error) {
62
  console.error("Failed to fetch organizations:", error);
63
  } finally {
64
  setLoading(false);
65
  }
66
  };
67
-
68
  useEffect(() => {
69
  fetchClients();
70
  }, [token]);
@@ -88,10 +106,13 @@ export default function ClientsManagementView() {
88
 
89
  const handleEmbeddedSignup = async (orgId: string) => {
90
  try {
91
- const signupData = await launchEmbeddedSignup();
92
-
93
- await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, signupData, token!);
94
-
 
 
 
95
  toast.success("WhatsApp connecté avec succès !");
96
  fetchClients();
97
  } catch (error: any) {
@@ -180,47 +201,13 @@ export default function ClientsManagementView() {
180
  </div>
181
 
182
  <div className="mt-8 pt-6 border-t border-slate-50 grid grid-cols-4 gap-6">
183
- <div className="flex items-center gap-3">
184
- <div className={`p-2 rounded-lg ${client.metaVerificationStatus === 'VERIFIED' ? 'bg-emerald-50' : 'bg-amber-50'}`}>
185
- <ShieldCheck className={`w-4 h-4 ${client.metaVerificationStatus === 'VERIFIED' ? 'text-emerald-500' : 'text-amber-500'}`} />
186
- </div>
187
- <div>
188
- <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Statut Meta</p>
189
- <div className="flex items-center gap-2">
190
- <p className="text-sm font-medium text-slate-700">
191
- {client.metaVerificationStatus === 'VERIFIED' ? 'Entreprise Vérifiée' : 'Non Vérifiée'}
192
- </p>
193
- {client.metaVerificationStatus !== 'VERIFIED' && (
194
- <button
195
- onClick={() => setShowGuide(true)}
196
- className="text-[10px] text-indigo-600 hover:underline font-bold"
197
- >
198
- Comment vérifier ? →
199
- </button>
200
- )}
201
- </div>
202
- </div>
203
- </div>
204
- <div className="flex items-center gap-3">
205
- <div className="p-2 bg-indigo-50 rounded-lg">
206
- <MessageSquare className="w-4 h-4 text-indigo-500" />
207
- </div>
208
- <div>
209
- <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Limite Quotidienne</p>
210
- <p className="text-sm font-medium text-slate-700">{client.dailyMessageLimit} conv.</p>
211
- </div>
212
- </div>
213
- <div className="flex items-center gap-3">
214
- <div className={`p-2 rounded-lg ${client.contractSigned ? 'bg-blue-50' : 'bg-slate-50'}`}>
215
- <Activity className={`w-4 h-4 ${client.contractSigned ? 'text-blue-500' : 'text-slate-400'}`} />
216
- </div>
217
- <div>
218
- <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Contrat PaaS</p>
219
- <p className={`text-sm font-medium ${client.contractSigned ? 'text-blue-600' : 'text-slate-500'}`}>
220
- {client.contractSigned ? 'Signé' : 'En attente'}
221
- </p>
222
- </div>
223
- </div>
224
  <div className="flex items-center justify-end">
225
  <button
226
  onClick={() => setBillingOrg(client)}
@@ -384,20 +371,16 @@ export default function ClientsManagementView() {
384
  <p className="font-semibold text-slate-800">{billingOrg.mode}</p>
385
  </div>
386
  <div className="bg-slate-50 rounded-2xl p-4">
387
- <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Statut Meta</p>
388
- <p className={`font-semibold ${billingOrg.metaVerificationStatus === 'VERIFIED' ? 'text-emerald-600' : 'text-amber-600'}`}>
389
- {billingOrg.metaVerificationStatus === 'VERIFIED' ? 'Vérifié' : 'Non vérifié'}
390
- </p>
391
  </div>
392
  <div className="bg-slate-50 rounded-2xl p-4">
393
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Limite quotidienne</p>
394
- <p className="font-semibold text-slate-800">{billingOrg.dailyMessageLimit} conv.</p>
395
  </div>
396
  <div className="bg-slate-50 rounded-2xl p-4">
397
- <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Contrat PaaS</p>
398
- <p className={`font-semibold ${billingOrg.contractSigned ? 'text-blue-600' : 'text-slate-500'}`}>
399
- {billingOrg.contractSigned ? `Signé${billingOrg.contractSignerName ? ' par ' + billingOrg.contractSignerName : ''}` : 'En attente'}
400
- </p>
401
  </div>
402
  </div>
403
  {billingOrg.wabaId && (
@@ -449,6 +432,117 @@ export default function ClientsManagementView() {
449
  );
450
  }
451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  function PersonalityStudioModal({ org, onClose, onSave }: PersonalityModalProps) {
453
  const [config, setConfig] = useState(org.personalityConfig || {
454
  botName: '',
 
1
  import { useState, useEffect } from 'react';
2
+ import { Building2, Plus, MessageSquare, ShieldCheck, Activity, Loader2, X, RefreshCw, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
3
  import { api } from '../lib/api';
4
  import { useAuth } from '../lib/auth';
5
  import { launchEmbeddedSignup } from '../lib/meta-signup';
 
9
  interface Organization {
10
  id: string;
11
  name: string;
 
12
  mode: 'EDTECH' | 'CRM_MARKETING' | 'PEDAGOGY' | 'CUSTOMER_SERVICE';
 
13
  wabaId?: string;
14
+ phoneNumbers?: { id: string; phoneNumber: string }[];
 
 
 
 
 
 
15
  personalityConfig?: {
16
  botName?: string;
17
  coreMission?: string;
 
19
  };
20
  }
21
 
22
+ interface MetaStatus {
23
+ configured: boolean;
24
+ wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN';
25
+ businessId?: string;
26
+ businessName?: string;
27
+ businessVerified?: boolean;
28
+ syncedAt?: string;
29
+ error?: string;
30
+ loading?: boolean;
31
+ }
32
+
33
  interface PersonalityModalProps {
34
  org: Organization;
35
  onClose: () => void;
 
55
  const [isCreating, setIsCreating] = useState(false);
56
  const [showGuide, setShowGuide] = useState(false);
57
  const [billingOrg, setBillingOrg] = useState<Organization | null>(null);
58
+ const [metaStatuses, setMetaStatuses] = useState<Record<string, MetaStatus>>({});
59
+
60
+ const fetchMetaStatus = async (orgId: string, force = false) => {
61
+ setMetaStatuses(prev => ({ ...prev, [orgId]: { ...prev[orgId], configured: false, loading: true } }));
62
+ try {
63
+ const status = force
64
+ ? await api.post(`/v1/organizations/${orgId}/meta-status/refresh`, {}, token!)
65
+ : await api.get(`/v1/organizations/${orgId}/meta-status`, token!);
66
+ setMetaStatuses(prev => ({ ...prev, [orgId]: { ...status, loading: false } }));
67
+ } catch {
68
+ setMetaStatuses(prev => ({ ...prev, [orgId]: { configured: false, loading: false, error: 'Erreur réseau' } }));
69
+ }
70
+ };
71
+
72
  const fetchClients = async () => {
73
  if (!token) return;
74
  try {
75
  const data = await api.get('/v1/organizations', token);
76
  setClients(data);
77
+ // Fetch Meta status for all orgs in parallel (non-blocking)
78
+ data.forEach((org: Organization) => fetchMetaStatus(org.id));
79
  } catch (error) {
80
  console.error("Failed to fetch organizations:", error);
81
  } finally {
82
  setLoading(false);
83
  }
84
  };
85
+
86
  useEffect(() => {
87
  fetchClients();
88
  }, [token]);
 
106
 
107
  const handleEmbeddedSignup = async (orgId: string) => {
108
  try {
109
+ const result = await launchEmbeddedSignup();
110
+ await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, {
111
+ wabaId: result.waba_id,
112
+ accessToken: result.code,
113
+ phoneNumberId: result.phone_number_id,
114
+ phoneNumber: ''
115
+ }, token!);
116
  toast.success("WhatsApp connecté avec succès !");
117
  fetchClients();
118
  } catch (error: any) {
 
201
  </div>
202
 
203
  <div className="mt-8 pt-6 border-t border-slate-50 grid grid-cols-4 gap-6">
204
+ <MetaStatusCell
205
+ ms={metaStatuses[client.id]}
206
+ onRefresh={() => fetchMetaStatus(client.id, true)}
207
+ onGuide={() => setShowGuide(true)}
208
+ />
209
+ <BusinessVerificationCell ms={metaStatuses[client.id]} />
210
+ <DailyLimitCell ms={metaStatuses[client.id]} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  <div className="flex items-center justify-end">
212
  <button
213
  onClick={() => setBillingOrg(client)}
 
371
  <p className="font-semibold text-slate-800">{billingOrg.mode}</p>
372
  </div>
373
  <div className="bg-slate-50 rounded-2xl p-4">
374
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">WABA Status</p>
375
+ <WabaStatusBadge ms={metaStatuses[billingOrg.id]} />
 
 
376
  </div>
377
  <div className="bg-slate-50 rounded-2xl p-4">
378
  <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Limite quotidienne</p>
379
+ <p className="font-semibold text-slate-800">{dailyLimitLabel(metaStatuses[billingOrg.id])}</p>
380
  </div>
381
  <div className="bg-slate-50 rounded-2xl p-4">
382
+ <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Entreprise Meta</p>
383
+ <BusinessBadge ms={metaStatuses[billingOrg.id]} />
 
 
384
  </div>
385
  </div>
386
  {billingOrg.wabaId && (
 
432
  );
433
  }
434
 
435
+ // ─── Meta status helpers ──────────────────────────────────────────────────────
436
+
437
+ function dailyLimitLabel(ms?: MetaStatus): string {
438
+ if (!ms || ms.loading) return '…';
439
+ if (!ms.configured) return '—';
440
+ if (ms.wabaStatus === 'APPROVED') return '1 000+ conv.';
441
+ if (ms.wabaStatus === 'REJECTED' || ms.wabaStatus === 'BANNED') return '0 conv.';
442
+ return '250 conv.';
443
+ }
444
+
445
+ function WabaStatusBadge({ ms }: { ms?: MetaStatus }) {
446
+ if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">Vérification…</span>;
447
+ if (!ms.configured) return <span className="text-slate-400 font-medium text-xs">Non connecté</span>;
448
+ const map: Record<string, { label: string; cls: string }> = {
449
+ APPROVED: { label: 'Approuvé', cls: 'text-emerald-600' },
450
+ PENDING: { label: 'En révision', cls: 'text-amber-600' },
451
+ REJECTED: { label: 'Rejeté', cls: 'text-red-600' },
452
+ BANNED: { label: 'Suspendu', cls: 'text-red-600' },
453
+ UNKNOWN: { label: 'Inconnu', cls: 'text-slate-500' },
454
+ };
455
+ const s = map[ms.wabaStatus ?? 'UNKNOWN'] ?? map.UNKNOWN;
456
+ return <span className={`font-semibold text-sm ${s.cls}`}>{s.label}</span>;
457
+ }
458
+
459
+ function BusinessBadge({ ms }: { ms?: MetaStatus }) {
460
+ if (!ms || ms.loading) return <span className="text-slate-400 text-xs animate-pulse">Vérification…</span>;
461
+ if (!ms.configured) return <span className="text-slate-400 font-medium text-xs">—</span>;
462
+ return ms.businessVerified
463
+ ? <span className="font-semibold text-sm text-emerald-600">Vérifiée</span>
464
+ : <span className="font-semibold text-sm text-amber-600">Non vérifiée</span>;
465
+ }
466
+
467
+ function MetaStatusCell({ ms, onRefresh, onGuide }: { ms?: MetaStatus; onRefresh: () => void; onGuide: () => void }) {
468
+ const isApproved = ms?.wabaStatus === 'APPROVED';
469
+ const isLoading = !ms || ms.loading;
470
+ return (
471
+ <div className="flex items-center gap-3">
472
+ <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : isApproved ? 'bg-emerald-50' : 'bg-amber-50'}`}>
473
+ {isLoading
474
+ ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" />
475
+ : isApproved
476
+ ? <CheckCircle2 className="w-4 h-4 text-emerald-500" />
477
+ : <AlertTriangle className="w-4 h-4 text-amber-500" />}
478
+ </div>
479
+ <div className="min-w-0">
480
+ <div className="flex items-center gap-1.5">
481
+ <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">WABA</p>
482
+ <button onClick={onRefresh} title="Rafraîchir" className="text-slate-300 hover:text-slate-500 transition">
483
+ <RefreshCw className="w-3 h-3" />
484
+ </button>
485
+ </div>
486
+ <div className="flex items-center gap-2 flex-wrap">
487
+ <WabaStatusBadge ms={ms} />
488
+ {ms && !isLoading && !isApproved && ms.configured && (
489
+ <button onClick={onGuide} className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap">
490
+ Que faire ? →
491
+ </button>
492
+ )}
493
+ </div>
494
+ {ms?.error && <p className="text-[10px] text-red-400 truncate">{ms.error}</p>}
495
+ </div>
496
+ </div>
497
+ );
498
+ }
499
+
500
+ function BusinessVerificationCell({ ms }: { ms?: MetaStatus }) {
501
+ const isLoading = !ms || ms.loading;
502
+ return (
503
+ <div className="flex items-center gap-3">
504
+ <div className={`p-2 rounded-lg ${isLoading ? 'bg-slate-50' : ms?.businessVerified ? 'bg-emerald-50' : 'bg-amber-50'}`}>
505
+ {isLoading
506
+ ? <ShieldCheck className="w-4 h-4 text-slate-300 animate-pulse" />
507
+ : ms?.businessVerified
508
+ ? <CheckCircle2 className="w-4 h-4 text-emerald-500" />
509
+ : <XCircle className="w-4 h-4 text-amber-500" />}
510
+ </div>
511
+ <div>
512
+ <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Entreprise Meta</p>
513
+ <div className="flex items-center gap-2">
514
+ <BusinessBadge ms={ms} />
515
+ {ms && !isLoading && ms.configured && !ms.businessVerified && (
516
+ <a
517
+ href="https://business.facebook.com/settings/security"
518
+ target="_blank" rel="noreferrer"
519
+ className="text-[10px] text-indigo-600 hover:underline font-bold whitespace-nowrap"
520
+ >
521
+ Vérifier →
522
+ </a>
523
+ )}
524
+ </div>
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ function DailyLimitCell({ ms }: { ms?: MetaStatus }) {
531
+ return (
532
+ <div className="flex items-center gap-3">
533
+ <div className="p-2 bg-indigo-50 rounded-lg">
534
+ <MessageSquare className="w-4 h-4 text-indigo-500" />
535
+ </div>
536
+ <div>
537
+ <p className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Limite Quotidienne</p>
538
+ <p className="text-sm font-medium text-slate-700">{dailyLimitLabel(ms)}</p>
539
+ </div>
540
+ </div>
541
+ );
542
+ }
543
+
544
+ // ─── Personality Studio Modal ─────────────────────────────────────────────────
545
+
546
  function PersonalityStudioModal({ org, onClose, onSave }: PersonalityModalProps) {
547
  const [config, setConfig] = useState(org.personalityConfig || {
548
  botName: '',
apps/admin/src/pages/OnboardingWizard.tsx CHANGED
@@ -1,29 +1,23 @@
1
  import { useState } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
- import {
4
- Smartphone,
5
- Bot,
6
- CheckCircle2,
7
- ArrowRight,
8
  ArrowLeft,
9
  Globe,
10
- Settings2,
11
  Rocket
12
  } from 'lucide-react';
13
  import { useAuth } from '../lib/auth';
14
  import { api } from '../lib/api';
15
- import { launchEmbeddedSignup } from '../lib/meta-signup';
16
- import { useToast } from '../hooks/useToast';
17
 
18
  const STEPS = [
19
  { id: 'welcome', title: 'Bienvenue', icon: <Rocket className="w-5 h-5" /> },
20
  { id: 'legal', title: 'Contrat', icon: <CheckCircle2 className="w-5 h-5" /> },
21
- { id: 'whatsapp', title: 'WhatsApp', icon: <Smartphone className="w-5 h-5" /> },
22
  { id: 'ai', title: 'Cerveau IA', icon: <Bot className="w-5 h-5" /> }
23
  ];
24
 
25
  export default function OnboardingWizard() {
26
- const toast = useToast();
27
  const [step, setStep] = useState(0);
28
  const [loading, setLoading] = useState(false);
29
  const { token } = useAuth();
@@ -36,9 +30,6 @@ export default function OnboardingWizard() {
36
  signerName: '',
37
  knowledgeBaseUrl: '',
38
  customPrompt: '',
39
- systemUserToken: '',
40
- wabaId: '',
41
- phoneNumberId: ''
42
  });
43
 
44
  const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
@@ -48,10 +39,10 @@ export default function OnboardingWizard() {
48
  setLoading(true);
49
  try {
50
  await api.post('/v1/organizations', {
51
- ...form,
52
- contractSigned: form.contractAccepted,
53
- contractSignedAt: new Date().toISOString(),
54
- contractSignerName: form.signerName
55
  }, token);
56
 
57
  navigate('/clients');
@@ -148,49 +139,6 @@ export default function OnboardingWizard() {
148
  )}
149
 
150
  {step === 2 && (
151
- <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 text-center py-8">
152
- <div className="w-20 h-20 bg-indigo-600 rounded-full flex items-center justify-center mx-auto mb-6 text-white shadow-xl shadow-indigo-200">
153
- <Smartphone className="w-10 h-10" />
154
- </div>
155
- <h2 className="text-2xl font-bold text-gray-900 mb-2">Connectez votre WhatsApp</h2>
156
- <p className="text-gray-500 mb-8 max-w-sm mx-auto">Plus besoin de copier des codes compliqués. Connectez-vous simplement avec votre compte Facebook.</p>
157
-
158
- {form.systemUserToken ? (
159
- <div className="bg-emerald-50 text-emerald-700 p-4 rounded-2xl flex items-center justify-center gap-3 animate-in zoom-in-95">
160
- <CheckCircle2 className="w-6 h-6" />
161
- <span className="font-bold">Compte Facebook connecté !</span>
162
- </div>
163
- ) : (
164
- <button
165
- className="bg-[#1877F2] hover:bg-[#166fe5] text-white px-10 py-4 rounded-2xl font-bold flex items-center gap-4 mx-auto transition-all shadow-lg shadow-blue-200 active:scale-95"
166
- onClick={async () => {
167
- try {
168
- const result = await launchEmbeddedSignup();
169
- setForm({
170
- ...form,
171
- systemUserToken: result.accessToken,
172
- wabaId: result.waba_id,
173
- phoneNumberId: result.phone_number_id
174
- });
175
- } catch (err: any) {
176
- toast.error(err.message);
177
- }
178
- }}
179
- >
180
- <div className="bg-white p-1 rounded">
181
- <svg width="20" height="20" viewBox="0 0 24 24" fill="#1877F2"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
182
- </div>
183
- Se connecter avec Facebook
184
- </button>
185
- )}
186
-
187
- <div className="mt-10 flex items-center justify-center gap-3 text-xs text-slate-400">
188
- <Settings2 className="w-4 h-4" /> Entièrement sécurisé par Meta API
189
- </div>
190
- </div>
191
- )}
192
-
193
- {step === 3 && (
194
  <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
195
  <h2 className="text-2xl font-bold text-gray-900 mb-2">Le Cerveau de votre IA</h2>
196
  <p className="text-gray-500 mb-8">L'endroit où votre assistant va puiser ses connaissances.</p>
 
1
  import { useState } from 'react';
2
  import { useNavigate } from 'react-router-dom';
3
+ import {
4
+ Bot,
5
+ CheckCircle2,
6
+ ArrowRight,
 
7
  ArrowLeft,
8
  Globe,
 
9
  Rocket
10
  } from 'lucide-react';
11
  import { useAuth } from '../lib/auth';
12
  import { api } from '../lib/api';
 
 
13
 
14
  const STEPS = [
15
  { id: 'welcome', title: 'Bienvenue', icon: <Rocket className="w-5 h-5" /> },
16
  { id: 'legal', title: 'Contrat', icon: <CheckCircle2 className="w-5 h-5" /> },
 
17
  { id: 'ai', title: 'Cerveau IA', icon: <Bot className="w-5 h-5" /> }
18
  ];
19
 
20
  export default function OnboardingWizard() {
 
21
  const [step, setStep] = useState(0);
22
  const [loading, setLoading] = useState(false);
23
  const { token } = useAuth();
 
30
  signerName: '',
31
  knowledgeBaseUrl: '',
32
  customPrompt: '',
 
 
 
33
  });
34
 
35
  const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1));
 
39
  setLoading(true);
40
  try {
41
  await api.post('/v1/organizations', {
42
+ name: form.name,
43
+ mode: form.mode,
44
+ knowledgeBaseUrl: form.knowledgeBaseUrl,
45
+ customPrompt: form.customPrompt
46
  }, token);
47
 
48
  navigate('/clients');
 
139
  )}
140
 
141
  {step === 2 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
143
  <h2 className="text-2xl font-bold text-gray-900 mb-2">Le Cerveau de votre IA</h2>
144
  <p className="text-gray-500 mb-8">L'endroit où votre assistant va puiser ses connaissances.</p>
apps/api/src/routes/organizations.ts CHANGED
@@ -90,11 +90,38 @@ export async function organizationRoutes(fastify: FastifyInstance) {
90
 
91
  const { accessToken, phoneNumber, phoneNumberId, wabaId } = body.data;
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  try {
94
  await prisma.$transaction(async (tx) => {
95
  await tx.organization.update({
96
  where: { id },
97
- data: encryptSecrets({ systemUserToken: accessToken, wabaId })
98
  });
99
 
100
  if (phoneNumberId) {
@@ -249,4 +276,18 @@ export async function organizationRoutes(fastify: FastifyInstance) {
249
  const result = await CRMService.bulkImportJson(id, parsed.data.contacts, parsed.data.listName);
250
  return { ok: true, ...result };
251
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  }
 
90
 
91
  const { accessToken, phoneNumber, phoneNumberId, wabaId } = body.data;
92
 
93
+ // Exchange OAuth code for a long-lived token if needed.
94
+ // Meta Embedded Signup returns a short-lived OAuth code (not starting with EAA).
95
+ let realToken = accessToken;
96
+ if (!accessToken.startsWith('EAA')) {
97
+ const appId = process.env.META_APP_ID;
98
+ const appSecret = process.env.WHATSAPP_APP_SECRET;
99
+ if (!appId || !appSecret) {
100
+ return reply.code(500).send({ error: 'META_APP_ID and WHATSAPP_APP_SECRET must be configured to exchange OAuth code' });
101
+ }
102
+ // Step 1: code → short-lived user access token
103
+ const exchangeRes = await fetch(
104
+ `https://graph.facebook.com/oauth/access_token?client_id=${appId}&client_secret=${appSecret}&code=${accessToken}&redirect_uri=`
105
+ );
106
+ const exchangeData = await exchangeRes.json() as { access_token?: string; error?: { message: string } };
107
+ if (!exchangeData.access_token) {
108
+ logger.error({ detail: exchangeData.error?.message }, '[WHATSAPP-SETUP] OAuth code exchange failed');
109
+ return reply.code(400).send({ error: 'Failed to exchange OAuth code with Meta', detail: exchangeData.error?.message });
110
+ }
111
+ // Step 2: short-lived → long-lived token (60 days)
112
+ const longLivedRes = await fetch(
113
+ `https://graph.facebook.com/oauth/access_token?grant_type=fb_exchange_token&client_id=${appId}&client_secret=${appSecret}&fb_exchange_token=${exchangeData.access_token}`
114
+ );
115
+ const longLivedData = await longLivedRes.json() as { access_token?: string };
116
+ realToken = longLivedData.access_token || exchangeData.access_token;
117
+ logger.info({ organizationId: id }, '[WHATSAPP-SETUP] OAuth code exchanged for long-lived token');
118
+ }
119
+
120
  try {
121
  await prisma.$transaction(async (tx) => {
122
  await tx.organization.update({
123
  where: { id },
124
+ data: encryptSecrets({ systemUserToken: realToken, wabaId })
125
  });
126
 
127
  if (phoneNumberId) {
 
276
  const result = await CRMService.bulkImportJson(id, parsed.data.contacts, parsed.data.listName);
277
  return { ok: true, ...result };
278
  });
279
+
280
+ // Meta Business Status — cached 1 hour, live from Meta Graph API
281
+ fastify.get<P>('/:id/meta-status', async (req) => {
282
+ const { id } = req.params;
283
+ const { fetchMetaStatus } = await import('../services/organization');
284
+ return fetchMetaStatus(id);
285
+ });
286
+
287
+ // Force-refresh Meta status cache for an org
288
+ fastify.post<P>('/:id/meta-status/refresh', async (req) => {
289
+ const { id } = req.params;
290
+ const { fetchMetaStatus } = await import('../services/organization');
291
+ return fetchMetaStatus(id, true);
292
+ });
293
  }
apps/api/src/services/organization.ts CHANGED
@@ -80,6 +80,72 @@ export function decryptSecrets(org: any) {
80
  return org;
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  /**
84
  * Retrieves all secrets for a tenant, decrypted.
85
  */
 
80
  return org;
81
  }
82
 
83
+ // ─── Meta Business Status ────────────────────────────────────────────────────
84
+
85
+ export interface MetaStatus {
86
+ configured: boolean;
87
+ wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN';
88
+ businessId?: string;
89
+ businessName?: string;
90
+ businessVerified?: boolean;
91
+ syncedAt: string;
92
+ error?: string;
93
+ }
94
+
95
+ export async function fetchMetaStatus(organizationId: string, forceRefresh = false): Promise<MetaStatus> {
96
+ const cacheKey = `meta:status:${organizationId}`;
97
+
98
+ if (!forceRefresh) {
99
+ try {
100
+ const cached = await redis.get(cacheKey);
101
+ if (cached) return JSON.parse(cached) as MetaStatus;
102
+ } catch (err) {
103
+ logger.error({ err }, '[META-STATUS] Redis get error');
104
+ }
105
+ }
106
+
107
+ const org = await prisma.organization.findUnique({ where: { id: organizationId } });
108
+ if (!org?.wabaId || !org?.systemUserToken) {
109
+ return { configured: false, syncedAt: new Date().toISOString() };
110
+ }
111
+
112
+ const decrypted = decryptSecrets({ ...org });
113
+
114
+ try {
115
+ const res = await fetch(
116
+ `https://graph.facebook.com/v19.0/${org.wabaId}?fields=account_review_status,business{id,name,verification_status}`,
117
+ { headers: { Authorization: `Bearer ${decrypted.systemUserToken}` } }
118
+ );
119
+ const data = await res.json() as any;
120
+
121
+ if (data.error) {
122
+ logger.warn({ err: data.error, organizationId }, '[META-STATUS] Meta API error');
123
+ const errResult: MetaStatus = { configured: true, wabaStatus: 'UNKNOWN', syncedAt: new Date().toISOString(), error: data.error.message };
124
+ await redis.set(cacheKey, JSON.stringify(errResult), 'EX', 300).catch(() => {});
125
+ return errResult;
126
+ }
127
+
128
+ const result: MetaStatus = {
129
+ configured: true,
130
+ wabaStatus: data.account_review_status ?? 'UNKNOWN',
131
+ businessId: data.business?.id,
132
+ businessName: data.business?.name,
133
+ businessVerified: data.business?.verification_status === 'verified',
134
+ syncedAt: new Date().toISOString()
135
+ };
136
+
137
+ await redis.set(cacheKey, JSON.stringify(result), 'EX', 3600).catch(err =>
138
+ logger.error({ err }, '[META-STATUS] Redis set error')
139
+ );
140
+ return result;
141
+ } catch (err) {
142
+ logger.error({ err, organizationId }, '[META-STATUS] Fetch failed');
143
+ return { configured: true, wabaStatus: 'UNKNOWN', syncedAt: new Date().toISOString(), error: 'Network error' };
144
+ }
145
+ }
146
+
147
+ // ─── Tenant Secrets ──────────────────────────────────────────────────────────
148
+
149
  /**
150
  * Retrieves all secrets for a tenant, decrypted.
151
  */
packages/shared-types/src/organization.ts CHANGED
@@ -39,11 +39,6 @@ export const OrganizationSchema = z.object({
39
  knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
40
  systemUserToken: z.string().optional(),
41
  brandingData: z.any().optional(),
42
- contractSigned: z.boolean().optional(),
43
- contractSignedAt: z.string().optional(),
44
- contractSignerName: z.string().optional(),
45
- metaVerificationStatus: z.string().optional(),
46
- dailyMessageLimit: z.number().optional()
47
  });
48
 
49
  export type Organization = z.infer<typeof OrganizationSchema>;
 
39
  knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
40
  systemUserToken: z.string().optional(),
41
  brandingData: z.any().optional(),
 
 
 
 
 
42
  });
43
 
44
  export type Organization = z.infer<typeof OrganizationSchema>;