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 +2 -2
- apps/admin/src/lib/meta-signup.ts +41 -28
- apps/admin/src/pages/ClientsManagementView.tsx +160 -66
- apps/admin/src/pages/OnboardingWizard.tsx +8 -60
- apps/api/src/routes/organizations.ts +42 -1
- apps/api/src/services/organization.ts +66 -0
- packages/shared-types/src/organization.ts +0 -5
|
@@ -23,10 +23,10 @@ export const WhatsAppConnectButton: React.FC = () => {
|
|
| 23 |
// 1. Launch Meta Popup
|
| 24 |
const result = await launchEmbeddedSignup();
|
| 25 |
|
| 26 |
-
// 2. Send
|
| 27 |
await api.post(`/v1/organizations/${orgId}/whatsapp-setup`, {
|
| 28 |
wabaId: result.waba_id,
|
| 29 |
-
accessToken: result.
|
| 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);
|
|
@@ -18,14 +18,13 @@ export const initMetaSDK = () => {
|
|
| 18 |
}
|
| 19 |
|
| 20 |
const initOptions = {
|
| 21 |
-
appId
|
| 22 |
-
cookie
|
| 23 |
-
xfbml
|
| 24 |
-
version
|
| 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
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
export const launchEmbeddedSignup = (): Promise<
|
| 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 |
-
//
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
} else if (data.event === 'CANCEL') {
|
|
|
|
|
|
|
| 60 |
reject(new Error("L'utilisateur a annulé l'onboarding."));
|
| 61 |
}
|
| 62 |
}
|
| 63 |
-
} catch
|
| 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 |
-
|
| 73 |
-
|
| 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 || '',
|
| 80 |
-
response_type: 'code',
|
| 81 |
-
override_default_response_type: true,
|
| 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 |
};
|
|
@@ -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
|
| 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
|
| 92 |
-
|
| 93 |
-
|
| 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 |
-
<
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 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">
|
| 388 |
-
<
|
| 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.
|
| 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">
|
| 398 |
-
<
|
| 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: '',
|
|
@@ -1,29 +1,23 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
-
import {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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>
|
|
@@ -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:
|
| 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 |
}
|
|
@@ -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 |
*/
|
|
@@ -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>;
|