CognxSafeTrack commited on
Commit ·
3992613
1
Parent(s): 38123ba
feat: implement PaaS modularity foundation (Contact model, modular OrganizationMode, and redesigned creation modal)
Browse files
apps/admin/src/pages/ClientsManagementView.tsx
CHANGED
|
@@ -36,7 +36,13 @@ export default function ClientsManagementView() {
|
|
| 36 |
const [loading, setLoading] = useState(true);
|
| 37 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 38 |
const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null);
|
| 39 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const [isCreating, setIsCreating] = useState(false);
|
| 41 |
const [showGuide, setShowGuide] = useState(false);
|
| 42 |
|
|
@@ -60,12 +66,13 @@ export default function ClientsManagementView() {
|
|
| 60 |
e.preventDefault();
|
| 61 |
setIsCreating(true);
|
| 62 |
try {
|
| 63 |
-
await api.post('/v1/organizations',
|
| 64 |
await fetchClients();
|
| 65 |
setIsModalOpen(false);
|
| 66 |
-
|
| 67 |
} catch (error) {
|
| 68 |
console.error("Failed to create organization:", error);
|
|
|
|
| 69 |
} finally {
|
| 70 |
setIsCreating(false);
|
| 71 |
}
|
|
@@ -236,32 +243,80 @@ export default function ClientsManagementView() {
|
|
| 236 |
|
| 237 |
{/* Modal de création d'organisation */}
|
| 238 |
{isModalOpen && (
|
| 239 |
-
<div className="fixed inset-0 bg-slate-900/
|
| 240 |
-
<div className="bg-white rounded-
|
| 241 |
-
<div className="flex items-center justify-between mb-
|
| 242 |
-
<h2 className="text-2xl font-
|
| 243 |
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-100 rounded-full transition">
|
| 244 |
<X className="w-5 h-5 text-slate-400" />
|
| 245 |
</button>
|
| 246 |
</div>
|
| 247 |
<form onSubmit={handleCreateOrg} className="space-y-6">
|
| 248 |
-
<div>
|
| 249 |
-
<
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
<button
|
| 260 |
type="submit"
|
| 261 |
disabled={isCreating}
|
| 262 |
-
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"
|
| 263 |
>
|
| 264 |
-
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Créer l\'organisation'}
|
| 265 |
</button>
|
| 266 |
</form>
|
| 267 |
</div>
|
|
|
|
| 36 |
const [loading, setLoading] = useState(true);
|
| 37 |
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 38 |
const [selectedOrgForPersonality, setSelectedOrgForPersonality] = useState<Organization | null>(null);
|
| 39 |
+
const [newOrg, setNewOrg] = useState({
|
| 40 |
+
name: '',
|
| 41 |
+
slug: '',
|
| 42 |
+
adminEmail: '',
|
| 43 |
+
adminName: '',
|
| 44 |
+
mode: 'PEDAGOGY' as const
|
| 45 |
+
});
|
| 46 |
const [isCreating, setIsCreating] = useState(false);
|
| 47 |
const [showGuide, setShowGuide] = useState(false);
|
| 48 |
|
|
|
|
| 66 |
e.preventDefault();
|
| 67 |
setIsCreating(true);
|
| 68 |
try {
|
| 69 |
+
await api.post('/v1/organizations', newOrg, token!);
|
| 70 |
await fetchClients();
|
| 71 |
setIsModalOpen(false);
|
| 72 |
+
setNewOrg({ name: '', slug: '', adminEmail: '', adminName: '', mode: 'PEDAGOGY' });
|
| 73 |
} catch (error) {
|
| 74 |
console.error("Failed to create organization:", error);
|
| 75 |
+
alert("Erreur lors de la création de l'organisation. Vérifiez que le slug est unique.");
|
| 76 |
} finally {
|
| 77 |
setIsCreating(false);
|
| 78 |
}
|
|
|
|
| 243 |
|
| 244 |
{/* Modal de création d'organisation */}
|
| 245 |
{isModalOpen && (
|
| 246 |
+
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-md flex items-center justify-center p-4 z-50">
|
| 247 |
+
<div className="bg-white rounded-[2rem] shadow-2xl w-full max-w-xl p-10 animate-in zoom-in-95 duration-200">
|
| 248 |
+
<div className="flex items-center justify-between mb-8">
|
| 249 |
+
<h2 className="text-2xl font-black text-slate-900">Nouvelle Organisation</h2>
|
| 250 |
<button onClick={() => setIsModalOpen(false)} className="p-2 hover:bg-slate-100 rounded-full transition">
|
| 251 |
<X className="w-5 h-5 text-slate-400" />
|
| 252 |
</button>
|
| 253 |
</div>
|
| 254 |
<form onSubmit={handleCreateOrg} className="space-y-6">
|
| 255 |
+
<div className="grid grid-cols-2 gap-4">
|
| 256 |
+
<div className="col-span-2">
|
| 257 |
+
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Nom de l'entreprise</label>
|
| 258 |
+
<input
|
| 259 |
+
type="text"
|
| 260 |
+
required
|
| 261 |
+
placeholder="Ex: AgroBusiness Senegal"
|
| 262 |
+
value={newOrg.name}
|
| 263 |
+
onChange={e => setNewOrg({...newOrg, name: e.target.value})}
|
| 264 |
+
className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition"
|
| 265 |
+
/>
|
| 266 |
+
</div>
|
| 267 |
+
<div>
|
| 268 |
+
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Slug (URL)</label>
|
| 269 |
+
<input
|
| 270 |
+
type="text"
|
| 271 |
+
required
|
| 272 |
+
placeholder="agro-sn"
|
| 273 |
+
value={newOrg.slug}
|
| 274 |
+
onChange={e => setNewOrg({...newOrg, slug: e.target.value.toLowerCase().replace(/\s+/g, '-')})}
|
| 275 |
+
className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition font-mono text-sm"
|
| 276 |
+
/>
|
| 277 |
+
</div>
|
| 278 |
+
<div>
|
| 279 |
+
<label className="block text-xs font-bold uppercase text-slate-400 mb-2">Cas d'usage</label>
|
| 280 |
+
<select
|
| 281 |
+
value={newOrg.mode}
|
| 282 |
+
onChange={e => setNewOrg({...newOrg, mode: e.target.value as any})}
|
| 283 |
+
className="w-full border border-slate-200 rounded-xl px-4 py-3 outline-none focus:ring-2 focus:ring-slate-900 transition bg-white"
|
| 284 |
+
>
|
| 285 |
+
<option value="CRM_MARKETING">CRM & Marketing</option>
|
| 286 |
+
<option value="PEDAGOGY">Pédagogie & Formation</option>
|
| 287 |
+
<option value="CUSTOMER_SERVICE">Service Client IA</option>
|
| 288 |
+
</select>
|
| 289 |
+
</div>
|
| 290 |
</div>
|
| 291 |
+
|
| 292 |
+
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 space-y-4">
|
| 293 |
+
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-widest">Administrateur Principal</h4>
|
| 294 |
+
<div className="grid grid-cols-2 gap-4">
|
| 295 |
+
<input
|
| 296 |
+
type="text"
|
| 297 |
+
required
|
| 298 |
+
placeholder="Nom complet"
|
| 299 |
+
value={newOrg.adminName}
|
| 300 |
+
onChange={e => setNewOrg({...newOrg, adminName: e.target.value})}
|
| 301 |
+
className="w-full border border-slate-200 rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-slate-900 transition"
|
| 302 |
+
/>
|
| 303 |
+
<input
|
| 304 |
+
type="email"
|
| 305 |
+
required
|
| 306 |
+
placeholder="Email"
|
| 307 |
+
value={newOrg.adminEmail}
|
| 308 |
+
onChange={e => setNewOrg({...newOrg, adminEmail: e.target.value})}
|
| 309 |
+
className="w-full border border-slate-200 rounded-xl px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-slate-900 transition"
|
| 310 |
+
/>
|
| 311 |
+
</div>
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
<button
|
| 315 |
type="submit"
|
| 316 |
disabled={isCreating}
|
| 317 |
+
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 shadow-xl shadow-slate-200"
|
| 318 |
>
|
| 319 |
+
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Créer l\'organisation & Admin'}
|
| 320 |
</button>
|
| 321 |
</form>
|
| 322 |
</div>
|
apps/api/src/routes/organizations.ts
CHANGED
|
@@ -11,20 +11,17 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 11 |
const OrganizationCreationSchema = OrganizationSchema.extend({
|
| 12 |
slug: z.string().min(3).regex(/^[a-z0-9-]+$/),
|
| 13 |
adminEmail: z.string().email(),
|
| 14 |
-
adminName: z.string().min(2)
|
|
|
|
| 15 |
});
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
coreMission: z.string().optional(),
|
| 20 |
-
toneDescription: z.string().optional(),
|
| 21 |
-
languageConstraints: z.string().optional()
|
| 22 |
-
});
|
| 23 |
// 1. List all organizations
|
| 24 |
fastify.get('/', async () => {
|
| 25 |
const orgs = await prisma.organization.findMany({
|
| 26 |
include: {
|
| 27 |
-
_count: { select: { users: true, enrollments: true } },
|
| 28 |
phoneNumbers: true
|
| 29 |
},
|
| 30 |
orderBy: { createdAt: 'desc' }
|
|
@@ -32,26 +29,14 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 32 |
return orgs.map(decryptSecrets);
|
| 33 |
});
|
| 34 |
|
| 35 |
-
//
|
| 36 |
-
fastify.get('/:id', async (req, reply) => {
|
| 37 |
-
const { id } = req.params as { id: string };
|
| 38 |
-
const org = await prisma.organization.findUnique({
|
| 39 |
-
where: { id },
|
| 40 |
-
include: {
|
| 41 |
-
phoneNumbers: true,
|
| 42 |
-
_count: { select: { users: true } }
|
| 43 |
-
}
|
| 44 |
-
});
|
| 45 |
-
if (!org) return reply.code(404).send({ error: 'Organization not found' });
|
| 46 |
-
return decryptSecrets(org);
|
| 47 |
-
});
|
| 48 |
|
| 49 |
// 3. Create a new organization + First Admin
|
| 50 |
fastify.post('/', async (req, reply) => {
|
| 51 |
const body = OrganizationCreationSchema.safeParse(req.body);
|
| 52 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 53 |
|
| 54 |
-
const { adminEmail, adminName, slug, ...orgData } = body.data;
|
| 55 |
|
| 56 |
// Check if slug already exists
|
| 57 |
const existing = await prisma.organization.findUnique({ where: { slug } });
|
|
@@ -62,7 +47,7 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 62 |
// Use a transaction to ensure both Org and User are created
|
| 63 |
const result = await prisma.$transaction(async (tx) => {
|
| 64 |
const org = await tx.organization.create({
|
| 65 |
-
data: { ...data, slug }
|
| 66 |
});
|
| 67 |
|
| 68 |
// Temporary password (user will reset it)
|
|
|
|
| 11 |
const OrganizationCreationSchema = OrganizationSchema.extend({
|
| 12 |
slug: z.string().min(3).regex(/^[a-z0-9-]+$/),
|
| 13 |
adminEmail: z.string().email(),
|
| 14 |
+
adminName: z.string().min(2),
|
| 15 |
+
mode: z.enum(['CRM_MARKETING', 'PEDAGOGY', 'CUSTOMER_SERVICE']).default('PEDAGOGY')
|
| 16 |
});
|
| 17 |
|
| 18 |
+
// ... ( PersonalityConfigSchema remains the same)
|
| 19 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
// 1. List all organizations
|
| 21 |
fastify.get('/', async () => {
|
| 22 |
const orgs = await prisma.organization.findMany({
|
| 23 |
include: {
|
| 24 |
+
_count: { select: { users: true, enrollments: true, contacts: true } },
|
| 25 |
phoneNumbers: true
|
| 26 |
},
|
| 27 |
orderBy: { createdAt: 'desc' }
|
|
|
|
| 29 |
return orgs.map(decryptSecrets);
|
| 30 |
});
|
| 31 |
|
| 32 |
+
// ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
// 3. Create a new organization + First Admin
|
| 35 |
fastify.post('/', async (req, reply) => {
|
| 36 |
const body = OrganizationCreationSchema.safeParse(req.body);
|
| 37 |
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 38 |
|
| 39 |
+
const { adminEmail, adminName, slug, mode, ...orgData } = body.data;
|
| 40 |
|
| 41 |
// Check if slug already exists
|
| 42 |
const existing = await prisma.organization.findUnique({ where: { slug } });
|
|
|
|
| 47 |
// Use a transaction to ensure both Org and User are created
|
| 48 |
const result = await prisma.$transaction(async (tx) => {
|
| 49 |
const org = await tx.organization.create({
|
| 50 |
+
data: { ...data, slug, mode: mode as any }
|
| 51 |
});
|
| 52 |
|
| 53 |
// Temporary password (user will reset it)
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -42,6 +42,21 @@ model Organization {
|
|
| 42 |
userBadges UserBadge[]
|
| 43 |
progress UserProgress[]
|
| 44 |
phoneNumbers WhatsAppPhoneNumber[]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
model KnowledgeBaseEntry {
|
|
@@ -316,6 +331,9 @@ enum OrganizationMode {
|
|
| 316 |
EDTECH
|
| 317 |
WEBHOOK
|
| 318 |
AI_AGENT
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
enum EnrollmentStatus {
|
|
|
|
| 42 |
userBadges UserBadge[]
|
| 43 |
progress UserProgress[]
|
| 44 |
phoneNumbers WhatsAppPhoneNumber[]
|
| 45 |
+
contacts Contact[]
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
model Contact {
|
| 49 |
+
id String @id @default(uuid())
|
| 50 |
+
phoneNumber String
|
| 51 |
+
name String?
|
| 52 |
+
attributes Json? // For dynamic Excel columns
|
| 53 |
+
organizationId String
|
| 54 |
+
createdAt DateTime @default(now())
|
| 55 |
+
updatedAt DateTime @updatedAt
|
| 56 |
+
organization Organization @relation(fields: [organizationId], references: [id])
|
| 57 |
+
|
| 58 |
+
@@unique([phoneNumber, organizationId])
|
| 59 |
+
@@index([organizationId])
|
| 60 |
}
|
| 61 |
|
| 62 |
model KnowledgeBaseEntry {
|
|
|
|
| 331 |
EDTECH
|
| 332 |
WEBHOOK
|
| 333 |
AI_AGENT
|
| 334 |
+
CRM_MARKETING
|
| 335 |
+
PEDAGOGY
|
| 336 |
+
CUSTOMER_SERVICE
|
| 337 |
}
|
| 338 |
|
| 339 |
enum EnrollmentStatus {
|
packages/shared-types/src/organization.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const OrganizationSchema = z.object({
|
|
| 26 |
name: z.string().min(1),
|
| 27 |
contactEmail: z.string().email().optional(),
|
| 28 |
customPrompt: z.string().optional(),
|
| 29 |
-
mode: z.enum(['EDTECH', 'WEBHOOK', 'AI_AGENT']).optional(),
|
| 30 |
flowConfig: FlowConfigSchema.optional(),
|
| 31 |
webhookUrl: z.string().url().optional().or(z.literal('')),
|
| 32 |
webhookSecret: z.string().optional(),
|
|
|
|
| 26 |
name: z.string().min(1),
|
| 27 |
contactEmail: z.string().email().optional(),
|
| 28 |
customPrompt: z.string().optional(),
|
| 29 |
+
mode: z.enum(['EDTECH', 'WEBHOOK', 'AI_AGENT', 'CRM_MARKETING', 'PEDAGOGY', 'CUSTOMER_SERVICE']).optional(),
|
| 30 |
flowConfig: FlowConfigSchema.optional(),
|
| 31 |
webhookUrl: z.string().url().optional().or(z.literal('')),
|
| 32 |
webhookSecret: z.string().optional(),
|