CognxSafeTrack commited on
Commit
ddc506d
·
1 Parent(s): ac6a112

feat: implement end-to-end B2B client management with Meta Embedded Signup

Browse files
apps/admin/index.html CHANGED
@@ -10,6 +10,8 @@
10
  <body>
11
  <div id="root"></div>
12
  <script type="module" src="/src/main.tsx"></script>
 
 
13
  </body>
14
 
15
  </html>
 
10
  <body>
11
  <div id="root"></div>
12
  <script type="module" src="/src/main.tsx"></script>
13
+ <!-- Meta SDK for WhatsApp Embedded Signup -->
14
+ <script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_US/sdk.js"></script>
15
  </body>
16
 
17
  </html>
apps/admin/src/lib/meta-signup.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utility for WhatsApp Embedded Signup via Meta SDK
3
+ */
4
+
5
+ declare global {
6
+ interface Window {
7
+ FB: any;
8
+ fbAsyncInit: () => void;
9
+ }
10
+ }
11
+
12
+ const META_APP_ID = import.meta.env.VITE_META_APP_ID;
13
+
14
+ export const initMetaSDK = () => {
15
+ if (!META_APP_ID) {
16
+ console.warn("[META-SDK] VITE_META_APP_ID is missing. Embedded signup will not work.");
17
+ return;
18
+ }
19
+
20
+ window.fbAsyncInit = function() {
21
+ window.FB.init({
22
+ appId : META_APP_ID,
23
+ cookie : true,
24
+ xfbml : true,
25
+ version : 'v19.0'
26
+ });
27
+ };
28
+ };
29
+
30
+ export interface MetaSignupResponse {
31
+ accessToken: string;
32
+ wabaId: string;
33
+ }
34
+
35
+ export const launchEmbeddedSignup = (): Promise<MetaSignupResponse> => {
36
+ return new Promise((resolve, reject) => {
37
+ if (!window.FB) {
38
+ return reject(new Error("Meta SDK not loaded"));
39
+ }
40
+
41
+ window.FB.login((response: any) => {
42
+ if (response.authResponse) {
43
+ // The user successfully logged in and authorized the app
44
+ // For WhatsApp Embedded Signup, we need the access token and the WABA ID
45
+ // Note: The WABA ID is often part of the extra data in the response
46
+ const accessToken = response.authResponse.accessToken;
47
+
48
+ // In a real Embedded Signup flow, Meta returns the WABA ID in the response
49
+ // through the 'whatsapp_business_account' setup.
50
+ const wabaId = response.authResponse.graphDomain === 'whatsapp' ? response.authResponse.userID : null;
51
+
52
+ console.log("[META-SDK] Login successful", response);
53
+
54
+ resolve({
55
+ accessToken,
56
+ wabaId: wabaId || "PENDING_SELECTION" // In some flows, WABA ID comes from a separate listener
57
+ });
58
+ } else {
59
+ reject(new Error("User cancelled login or did not fully authorize."));
60
+ }
61
+ }, {
62
+ // Scopes required for Tech Providers
63
+ scope: 'whatsapp_business_management,whatsapp_business_messaging,manage_app_solution,business_management',
64
+ extras: {
65
+ feature: 'whatsapp_embedded_signup',
66
+ setup: {
67
+ business: {
68
+ name: 'XAMLÉ Partner'
69
+ }
70
+ }
71
+ }
72
+ });
73
+ });
74
+ };
apps/admin/src/main.tsx CHANGED
@@ -2,6 +2,9 @@ import React from 'react'
2
  import ReactDOM from 'react-dom/client'
3
  import App from './App.tsx'
4
  import './index.css'
 
 
 
5
 
6
  ReactDOM.createRoot(document.getElementById('root')!).render(
7
  <React.StrictMode>
 
2
  import ReactDOM from 'react-dom/client'
3
  import App from './App.tsx'
4
  import './index.css'
5
+ import { initMetaSDK } from './lib/meta-signup'
6
+
7
+ initMetaSDK();
8
 
9
  ReactDOM.createRoot(document.getElementById('root')!).render(
10
  <React.StrictMode>
apps/admin/src/pages/ClientsManagementView.tsx CHANGED
@@ -1,30 +1,99 @@
1
- import { useState } from 'react';
2
- import { Building2, Plus, MessageSquare, ShieldCheck, ExternalLink, Activity } from 'lucide-react';
 
 
 
3
 
4
  interface Organization {
5
  id: string;
6
  name: string;
7
  status: 'ACTIVE' | 'PENDING' | 'CONFIG_REQUIRED';
8
  wabaId?: string;
9
- phoneNumber?: string;
10
  lastActivity?: string;
11
  }
12
 
13
- const MOCK_CLIENTS: Organization[] = [
14
- { id: '1', name: 'AgroBusiness Senegal', status: 'ACTIVE', wabaId: '23849102394', phoneNumber: '+221 77 123 45 67', lastActivity: 'Il y a 5 min' },
15
- { id: '2', name: 'Education Pour Tous', status: 'CONFIG_REQUIRED', wabaId: undefined, phoneNumber: undefined },
16
- { id: '3', name: 'Sammante Tech', status: 'ACTIVE', wabaId: '99283102312', phoneNumber: '+221 70 987 65 43', lastActivity: 'Hier' },
17
- ];
18
-
19
  export default function ClientsManagementView() {
20
- const [clients] = useState<Organization[]>(MOCK_CLIENTS);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
- const handleEmbeddedSignup = () => {
23
- // Simulation du Meta Embedded Signup Flow
24
- alert("Ouverture du flux d'inscription intégré Meta (Embedded Signup)...\n\n1. Connexion au compte Business\n2. Sélection du WABA\n3. Vérification du numéro de téléphone");
25
- console.log("Meta SDK Trigger: FB.login with extras={setup: 'whatsapp_business_account'}");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  };
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return (
29
  <div className="p-8 max-w-6xl mx-auto">
30
  <div className="flex items-center justify-between mb-8">
@@ -32,13 +101,22 @@ export default function ClientsManagementView() {
32
  <h1 className="text-3xl font-bold tracking-tight text-slate-900">Gestion des Clients B2B</h1>
33
  <p className="text-slate-500 mt-2">Gérez les organisations partenaires et leurs actifs WhatsApp.</p>
34
  </div>
35
- <button className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-medium hover:bg-slate-800 transition shadow-sm">
 
 
 
36
  <Plus className="w-4 h-4" /> Nouvelle Organisation
37
  </button>
38
  </div>
39
 
40
  <div className="grid gap-6">
41
- {clients.map(client => (
 
 
 
 
 
 
42
  <div key={client.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition">
43
  <div className="flex items-start justify-between">
44
  <div className="flex gap-4">
@@ -49,30 +127,30 @@ export default function ClientsManagementView() {
49
  <h3 className="text-xl font-bold text-slate-900">{client.name}</h3>
50
  <div className="flex items-center gap-3 mt-1">
51
  <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold ${
52
- client.status === 'ACTIVE' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
53
  }`}>
54
- {client.status === 'ACTIVE' ? 'Opérationnel' : 'Configuration requise'}
55
  </span>
56
  <span className="text-xs text-slate-400">ID: {client.id}</span>
57
  </div>
58
  </div>
59
  </div>
60
  <div className="flex gap-2">
61
- {client.status === 'ACTIVE' ? (
62
  <div className="flex items-center gap-4 text-sm">
63
  <div className="text-right">
64
- <p className="font-medium text-slate-700">{client.phoneNumber}</p>
65
- <p className="text-xs text-slate-400">WABA: {client.wabaId}</p>
66
  </div>
67
  <div className="h-10 w-px bg-slate-100"></div>
68
  <div className="flex items-center gap-2 text-emerald-600">
69
  <Activity className="w-4 h-4" />
70
- <span className="font-medium text-xs">{client.lastActivity}</span>
71
  </div>
72
  </div>
73
  ) : (
74
  <button
75
- onClick={handleEmbeddedSignup}
76
  className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-100"
77
  >
78
  <MessageSquare className="w-4 h-4" /> Connecter WhatsApp (Meta)
@@ -110,6 +188,40 @@ export default function ClientsManagementView() {
110
  ))}
111
  </div>
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  {/* Meta Compliance Footer */}
114
  <div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100">
115
  <h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Meta Tech Provider Compliance</h4>
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Building2, Plus, MessageSquare, ShieldCheck, ExternalLink, Activity, Loader2, X } from 'lucide-react';
3
+ import { API_URL, ah } from '../lib/api';
4
+ import { useAuth } from '../lib/auth';
5
+ import { launchEmbeddedSignup } from '../lib/meta-signup';
6
 
7
  interface Organization {
8
  id: string;
9
  name: string;
10
  status: 'ACTIVE' | 'PENDING' | 'CONFIG_REQUIRED';
11
  wabaId?: string;
12
+ phoneNumbers?: { id: string, phoneNumber: string }[];
13
  lastActivity?: string;
14
  }
15
 
 
 
 
 
 
 
16
  export default function ClientsManagementView() {
17
+ const { apiKey } = useAuth();
18
+ const [clients, setClients] = useState<Organization[]>([]);
19
+ const [loading, setLoading] = useState(true);
20
+ const [isModalOpen, setIsModalOpen] = useState(false);
21
+ const [newOrgName, setNewOrgName] = useState('');
22
+ const [isCreating, setIsCreating] = useState(false);
23
+
24
+ const fetchClients = async () => {
25
+ try {
26
+ const res = await fetch(`${API_URL}/v1/organizations`, {
27
+ headers: ah(apiKey || '')
28
+ });
29
+ if (res.ok) {
30
+ const data = await res.json();
31
+ setClients(data);
32
+ }
33
+ } catch (error) {
34
+ console.error("Failed to fetch organizations:", error);
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ };
39
+
40
+ useEffect(() => {
41
+ fetchClients();
42
+ }, [apiKey]);
43
 
44
+ const handleCreateOrg = async (e: React.FormEvent) => {
45
+ e.preventDefault();
46
+ setIsCreating(true);
47
+ try {
48
+ const res = await fetch(`${API_URL}/v1/organizations`, {
49
+ method: 'POST',
50
+ headers: ah(apiKey || ''),
51
+ body: JSON.stringify({ name: newOrgName })
52
+ });
53
+ if (res.ok) {
54
+ await fetchClients();
55
+ setIsModalOpen(false);
56
+ setNewOrgName('');
57
+ }
58
+ } catch (error) {
59
+ console.error("Failed to create organization:", error);
60
+ } finally {
61
+ setIsCreating(false);
62
+ }
63
  };
64
 
65
+ const handleEmbeddedSignup = async (orgId: string) => {
66
+ try {
67
+ const signupData = await launchEmbeddedSignup();
68
+
69
+ // Send the received credentials to our backend
70
+ const res = await fetch(`${API_URL}/v1/organizations/${orgId}/whatsapp-setup`, {
71
+ method: 'POST',
72
+ headers: ah(apiKey || ''),
73
+ body: JSON.stringify(signupData)
74
+ });
75
+
76
+ if (res.ok) {
77
+ alert("WhatsApp connecté avec succès !");
78
+ fetchClients(); // Refresh list
79
+ } else {
80
+ const err = await res.json();
81
+ alert(`Erreur lors de l'enregistrement : ${err.error}`);
82
+ }
83
+ } catch (error: any) {
84
+ alert(`Erreur Meta : ${error.message}`);
85
+ }
86
+ };
87
+
88
+ if (loading) {
89
+ return (
90
+ <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400">
91
+ <Loader2 className="w-8 h-8 animate-spin mb-4" />
92
+ <p className="font-medium">Chargement des clients...</p>
93
+ </div>
94
+ );
95
+ }
96
+
97
  return (
98
  <div className="p-8 max-w-6xl mx-auto">
99
  <div className="flex items-center justify-between mb-8">
 
101
  <h1 className="text-3xl font-bold tracking-tight text-slate-900">Gestion des Clients B2B</h1>
102
  <p className="text-slate-500 mt-2">Gérez les organisations partenaires et leurs actifs WhatsApp.</p>
103
  </div>
104
+ <button
105
+ onClick={() => setIsModalOpen(true)}
106
+ className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl font-medium hover:bg-slate-800 transition shadow-sm"
107
+ >
108
  <Plus className="w-4 h-4" /> Nouvelle Organisation
109
  </button>
110
  </div>
111
 
112
  <div className="grid gap-6">
113
+ {clients.length === 0 ? (
114
+ <div className="text-center py-20 bg-slate-50 rounded-3xl border-2 border-dashed border-slate-200">
115
+ <Building2 className="w-12 h-12 text-slate-300 mx-auto mb-4" />
116
+ <h3 className="text-lg font-bold text-slate-900">Aucun client</h3>
117
+ <p className="text-slate-500">Commencez par ajouter votre première organisation partenaire.</p>
118
+ </div>
119
+ ) : clients.map(client => (
120
  <div key={client.id} className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md transition">
121
  <div className="flex items-start justify-between">
122
  <div className="flex gap-4">
 
127
  <h3 className="text-xl font-bold text-slate-900">{client.name}</h3>
128
  <div className="flex items-center gap-3 mt-1">
129
  <span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold ${
130
+ client.phoneNumbers?.length ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
131
  }`}>
132
+ {client.phoneNumbers?.length ? 'Opérationnel' : 'Configuration requise'}
133
  </span>
134
  <span className="text-xs text-slate-400">ID: {client.id}</span>
135
  </div>
136
  </div>
137
  </div>
138
  <div className="flex gap-2">
139
+ {client.phoneNumbers?.length ? (
140
  <div className="flex items-center gap-4 text-sm">
141
  <div className="text-right">
142
+ <p className="font-medium text-slate-700">{client.phoneNumbers[0].phoneNumber}</p>
143
+ <p className="text-xs text-slate-400">ID: {client.phoneNumbers[0].id}</p>
144
  </div>
145
  <div className="h-10 w-px bg-slate-100"></div>
146
  <div className="flex items-center gap-2 text-emerald-600">
147
  <Activity className="w-4 h-4" />
148
+ <span className="font-medium text-xs">En ligne</span>
149
  </div>
150
  </div>
151
  ) : (
152
  <button
153
+ onClick={() => handleEmbeddedSignup(client.id)}
154
  className="flex items-center gap-2 bg-indigo-600 text-white px-5 py-2.5 rounded-xl font-semibold hover:bg-indigo-700 transition shadow-lg shadow-indigo-100"
155
  >
156
  <MessageSquare className="w-4 h-4" /> Connecter WhatsApp (Meta)
 
188
  ))}
189
  </div>
190
 
191
+ {/* Modal de création d'organisation */}
192
+ {isModalOpen && (
193
+ <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 z-50">
194
+ <div className="bg-white rounded-3xl shadow-2xl w-full max-w-md p-8 animate-in zoom-in-95 duration-200">
195
+ <div className="flex items-center justify-between mb-6">
196
+ <h2 className="text-2xl font-bold text-slate-900">Nouvelle Organisation</h2>
197
+ <button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-100 rounded-full transition">
198
+ <X className="w-5 h-5 text-slate-400" />
199
+ </button>
200
+ </div>
201
+ <form onSubmit={handleCreateOrg} className="space-y-6">
202
+ <div>
203
+ <label className="block text-sm font-bold text-slate-700 mb-2">Nom de l'entreprise</label>
204
+ <input
205
+ type="text"
206
+ required
207
+ placeholder="Ex: AgroBusiness Senegal"
208
+ value={newOrgName}
209
+ onChange={e => setNewOrgName(e.target.value)}
210
+ className="w-full border border-slate-200 rounded-2xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition"
211
+ />
212
+ </div>
213
+ <button
214
+ type="submit"
215
+ disabled={isCreating}
216
+ className="w-full bg-slate-900 text-white py-4 rounded-2xl font-bold hover:bg-slate-800 transition disabled:opacity-50 flex items-center justify-center gap-2"
217
+ >
218
+ {isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Créer l\'organisation'}
219
+ </button>
220
+ </form>
221
+ </div>
222
+ </div>
223
+ )}
224
+
225
  {/* Meta Compliance Footer */}
226
  <div className="mt-12 p-6 bg-slate-50 rounded-2xl border border-slate-100">
227
  <h4 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Meta Tech Provider Compliance</h4>
apps/api/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { aiRoutes } from './routes/ai';
10
  import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
11
  import { internalRoutes } from './routes/internal';
12
  import { studentRoutes } from './routes/student';
 
13
  import { startCleanupCron } from './services/cleanup';
14
  import { PrismaClient } from '@repo/database';
15
 
@@ -104,6 +105,7 @@ server.register(async function guardedRoutes(scope) {
104
  });
105
 
106
  scope.register(adminRoutes, { prefix: '/v1/admin' });
 
107
  scope.register(aiRoutes, { prefix: '/v1/ai' });
108
  scope.register(paymentRoutes, { prefix: '/v1/payments' });
109
  scope.register(internalRoutes);
 
10
  import { paymentRoutes, stripeWebhookRoute } from './routes/payments';
11
  import { internalRoutes } from './routes/internal';
12
  import { studentRoutes } from './routes/student';
13
+ import { organizationRoutes } from './routes/organizations';
14
  import { startCleanupCron } from './services/cleanup';
15
  import { PrismaClient } from '@repo/database';
16
 
 
105
  });
106
 
107
  scope.register(adminRoutes, { prefix: '/v1/admin' });
108
+ scope.register(organizationRoutes, { prefix: '/v1/organizations' });
109
  scope.register(aiRoutes, { prefix: '/v1/ai' });
110
  scope.register(paymentRoutes, { prefix: '/v1/payments' });
111
  scope.register(internalRoutes);
apps/api/src/routes/organizations.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FastifyInstance } from 'fastify';
2
+ import { prisma } from '../services/prisma';
3
+ import { z } from 'zod';
4
+
5
+ const OrganizationSchema = z.object({
6
+ name: z.string().min(1),
7
+ contactEmail: z.string().email().optional(),
8
+ customPrompt: z.string().optional(),
9
+ });
10
+
11
+ export async function organizationRoutes(fastify: FastifyInstance) {
12
+ // 1. List all organizations
13
+ fastify.get('/', async () => {
14
+ return prisma.organization.findMany({
15
+ include: {
16
+ _count: { select: { users: true, enrollments: true } },
17
+ phoneNumbers: true
18
+ },
19
+ orderBy: { createdAt: 'desc' }
20
+ });
21
+ });
22
+
23
+ // 2. Get single organization details
24
+ fastify.get('/:id', async (req, reply) => {
25
+ const { id } = req.params as { id: string };
26
+ const org = await prisma.organization.findUnique({
27
+ where: { id },
28
+ include: {
29
+ phoneNumbers: true,
30
+ _count: { select: { users: true } }
31
+ }
32
+ });
33
+ if (!org) return reply.code(404).send({ error: 'Organization not found' });
34
+ return org;
35
+ });
36
+
37
+ // 3. Create a new organization
38
+ fastify.post('/', async (req, reply) => {
39
+ const body = OrganizationSchema.safeParse(req.body);
40
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
41
+
42
+ const org = await prisma.organization.create({
43
+ data: body.data
44
+ });
45
+ return reply.code(201).send(org);
46
+ });
47
+
48
+ // 4. Update organization (Branding, Prompt, etc.)
49
+ fastify.put('/:id', async (req, reply) => {
50
+ const { id } = req.params as { id: string };
51
+ const body = OrganizationSchema.partial().extend({
52
+ systemUserToken: z.string().optional(),
53
+ brandingData: z.any().optional()
54
+ }).safeParse(req.body);
55
+
56
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
57
+
58
+ try {
59
+ const org = await prisma.organization.update({
60
+ where: { id },
61
+ data: body.data
62
+ });
63
+ return org;
64
+ } catch (err) {
65
+ return reply.code(404).send({ error: 'Organization not found' });
66
+ }
67
+ });
68
+
69
+ // 5. Meta Callback Handler (Placeholder for receiving WABA ID and Token)
70
+ fastify.post('/:id/whatsapp-setup', async (req, reply) => {
71
+ const { id } = req.params as { id: string };
72
+ const schema = z.object({
73
+ wabaId: z.string(),
74
+ accessToken: z.string(),
75
+ phoneNumber: z.string().optional(),
76
+ phoneNumberId: z.string().optional()
77
+ });
78
+
79
+ const body = schema.safeParse(req.body);
80
+ if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
81
+
82
+ const { wabaId, accessToken, phoneNumber, phoneNumberId } = body.data;
83
+
84
+ // Update Organization with the permanent token
85
+ await prisma.organization.update({
86
+ where: { id },
87
+ data: {
88
+ systemUserToken: accessToken
89
+ }
90
+ });
91
+
92
+ // Upsert the Phone Number associated with this organization
93
+ if (phoneNumberId) {
94
+ await (prisma as any).whatsAppPhoneNumber.upsert({
95
+ where: { id: phoneNumberId },
96
+ update: {
97
+ phoneNumber: phoneNumber || '',
98
+ organizationId: id
99
+ },
100
+ create: {
101
+ id: phoneNumberId,
102
+ phoneNumber: phoneNumber || '',
103
+ organizationId: id
104
+ }
105
+ });
106
+ }
107
+
108
+ return { ok: true, message: 'WhatsApp configuration updated successfully' };
109
+ });
110
+ }