CognxSafeTrack commited on
Commit ·
7b4936e
1
Parent(s): 6da0dd6
feat(ai): team building visual experience and persistence
Browse files
apps/api/src/routes/ai.ts
CHANGED
|
@@ -172,18 +172,26 @@ export async function aiRoutes(fastify: FastifyInstance) {
|
|
| 172 |
|
| 173 |
const { audioBase64, mimeType, phone } = bodySchema.parse(request.body);
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
const buffer = Buffer.from(audioBase64, 'base64');
|
| 178 |
|
| 179 |
try {
|
| 180 |
-
// Ensure the /tmp
|
| 181 |
-
// This prevents ENOENT errors if the container restarted
|
| 182 |
const { mkdir } = require('fs/promises');
|
| 183 |
-
await mkdir(
|
| 184 |
|
| 185 |
const url = await uploadFile(buffer, filename, mimeType);
|
| 186 |
-
console.log(`[AI] ✅
|
| 187 |
return { success: true, url };
|
| 188 |
} catch (err: unknown) {
|
| 189 |
console.error('[AI] store-audio failed:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
|
|
|
|
| 172 |
|
| 173 |
const { audioBase64, mimeType, phone } = bodySchema.parse(request.body);
|
| 174 |
|
| 175 |
+
let ext = 'ogg';
|
| 176 |
+
let folder = 'audio';
|
| 177 |
+
if (mimeType.includes('image/')) {
|
| 178 |
+
ext = mimeType.split('/')[1] || 'jpeg';
|
| 179 |
+
folder = 'images';
|
| 180 |
+
} else if (mimeType.includes('mp4')) {
|
| 181 |
+
ext = 'mp4';
|
| 182 |
+
} else if (mimeType.includes('mpeg')) {
|
| 183 |
+
ext = 'mp3';
|
| 184 |
+
}
|
| 185 |
+
const filename = `${folder}/${phone}-${Date.now()}.${ext}`;
|
| 186 |
const buffer = Buffer.from(audioBase64, 'base64');
|
| 187 |
|
| 188 |
try {
|
| 189 |
+
// Ensure the /tmp folders exist because uploadFile falls back to local storage
|
|
|
|
| 190 |
const { mkdir } = require('fs/promises');
|
| 191 |
+
await mkdir(`/tmp/${folder}`, { recursive: true }).catch(() => { });
|
| 192 |
|
| 193 |
const url = await uploadFile(buffer, filename, mimeType);
|
| 194 |
+
console.log(`[AI] ✅ Media stored: ${url}`);
|
| 195 |
return { success: true, url };
|
| 196 |
} catch (err: unknown) {
|
| 197 |
console.error('[AI] store-audio failed:', (err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err)));
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -103,6 +103,10 @@ class AIService {
|
|
| 103 |
? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
|
| 104 |
: '';
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
const prompt = `
|
| 107 |
Tu es un expert en Pitch Decks internationaux (VC-ready).
|
| 108 |
Génère un deck de 13 slides STRICTEMENT basé sur la structure suivante pour ce business :
|
|
@@ -128,6 +132,7 @@ class AIService {
|
|
| 128 |
USER INPUT:
|
| 129 |
${userContext}
|
| 130 |
${marketDataInjected}
|
|
|
|
| 131 |
|
| 132 |
STRICTES CONTRAINTES DE QUALITÉ "GENSPARK-STANDARD" :
|
| 133 |
- STORYTELLING (CRUCIAL) : Rédige de véritables petits paragraphes narratifs (2 à 3 phrases, 15-25 mots par bloc). INTERDICTION TOTALE d'utiliser des listes à puces ("bullet points") classiques avec des mots isolés. Raconte une histoire convaincante pour un investisseur.
|
|
@@ -135,6 +140,7 @@ class AIService {
|
|
| 135 |
- ANTI-JARGON SAAS : INTERDICTION FORMELLE d'utiliser les mots "Premium", "Trial", "Subscription", "Sign up" ou "SaaS". Le Business Model doit être 100% réaliste pour le secteur local (Vente au kilo, Prestation, Acompte, GMS).
|
| 136 |
- ANALYSE vs DESCRIPTION : Ne décris pas, analyse. (Ex: Au lieu de 'Délais non respectés', dis 'L'instabilité chronique des délais de livraison artisanaux dégrade l'expérience client et réduit le taux de réachat').
|
| 137 |
- Slide 5 (Marché) : visualType = 'PIE_CHART', visualData = { labels: ["TAM (Total)", "SAM (Cible)", "SOM (Capturable)"], values: [1000000, 500000, 50000] }. Utilise le cascade : National > Régional > UEMOA. La source (ex: "Source: ANSD 2024") DOIT ÊTRE EXPLICITEMENT CITÉE en bas du slide dans content ou notes.
|
|
|
|
| 138 |
- Slide 11 (Finances) : visualType = 'BAR_CHART', visualData = { labels: ["Année 1", "Année 2", "Année 3", "Année 4", "Année 5"], values: [100, 200, 400, 800, 1600] }. Explique la LOGIQUE de croissance financière de manière narrative.
|
| 139 |
- STRICTEMENT 3 à 4 blocs de texte par slide. Aucun bloc de moins de 15 mots.
|
| 140 |
- DÉTANCHÉITÉ LINGUISTIQUE :
|
|
@@ -247,6 +253,26 @@ class AIService {
|
|
| 247 |
IMPORTANT : Interdiction de poser une question à la fin. Exige seulement à l'utilisateur de taper "SUITE" pour clôturer cette étape.
|
| 248 |
MET isForcedClosure À TRUE DANS TA RÉPONSE JSON.
|
| 249 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
} else {
|
| 251 |
actionPrompt = `
|
| 252 |
🔄 ANALYSE ITÉRATIVE "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
|
|
|
|
| 103 |
? `\n🌐 DONNÉES DE MARCHÉ (RECHERCHE WEB) :\n${JSON.stringify(businessProfile.marketData, null, 2)}\n`
|
| 104 |
: '';
|
| 105 |
|
| 106 |
+
const teamDataInjected = businessProfile?.teamMembers && Array.isArray(businessProfile.teamMembers) && businessProfile.teamMembers.length > 0
|
| 107 |
+
? `\n👥 MEMBRES DE L'ÉQUIPE (PHOTOS/BDD) :\n${JSON.stringify(businessProfile.teamMembers, null, 2)}\n`
|
| 108 |
+
: '';
|
| 109 |
+
|
| 110 |
const prompt = `
|
| 111 |
Tu es un expert en Pitch Decks internationaux (VC-ready).
|
| 112 |
Génère un deck de 13 slides STRICTEMENT basé sur la structure suivante pour ce business :
|
|
|
|
| 132 |
USER INPUT:
|
| 133 |
${userContext}
|
| 134 |
${marketDataInjected}
|
| 135 |
+
${teamDataInjected}
|
| 136 |
|
| 137 |
STRICTES CONTRAINTES DE QUALITÉ "GENSPARK-STANDARD" :
|
| 138 |
- STORYTELLING (CRUCIAL) : Rédige de véritables petits paragraphes narratifs (2 à 3 phrases, 15-25 mots par bloc). INTERDICTION TOTALE d'utiliser des listes à puces ("bullet points") classiques avec des mots isolés. Raconte une histoire convaincante pour un investisseur.
|
|
|
|
| 140 |
- ANTI-JARGON SAAS : INTERDICTION FORMELLE d'utiliser les mots "Premium", "Trial", "Subscription", "Sign up" ou "SaaS". Le Business Model doit être 100% réaliste pour le secteur local (Vente au kilo, Prestation, Acompte, GMS).
|
| 141 |
- ANALYSE vs DESCRIPTION : Ne décris pas, analyse. (Ex: Au lieu de 'Délais non respectés', dis 'L'instabilité chronique des délais de livraison artisanaux dégrade l'expérience client et réduit le taux de réachat').
|
| 142 |
- Slide 5 (Marché) : visualType = 'PIE_CHART', visualData = { labels: ["TAM (Total)", "SAM (Cible)", "SOM (Capturable)"], values: [1000000, 500000, 50000] }. Utilise le cascade : National > Régional > UEMOA. La source (ex: "Source: ANSD 2024") DOIT ÊTRE EXPLICITEMENT CITÉE en bas du slide dans content ou notes.
|
| 143 |
+
- Slide Équipe (10) : Si des données d'équipe existent, définis IMPERATIVEMENT visualType = 'TEAM' et place le tableau EXACT JSON fourni dans MEMBRES DE L'ÉQUIPE dans visualData.
|
| 144 |
- Slide 11 (Finances) : visualType = 'BAR_CHART', visualData = { labels: ["Année 1", "Année 2", "Année 3", "Année 4", "Année 5"], values: [100, 200, 400, 800, 1600] }. Explique la LOGIQUE de croissance financière de manière narrative.
|
| 145 |
- STRICTEMENT 3 à 4 blocs de texte par slide. Aucun bloc de moins de 15 mots.
|
| 146 |
- DÉTANCHÉITÉ LINGUISTIQUE :
|
|
|
|
| 253 |
IMPORTANT : Interdiction de poser une question à la fin. Exige seulement à l'utilisateur de taper "SUITE" pour clôturer cette étape.
|
| 254 |
MET isForcedClosure À TRUE DANS TA RÉPONSE JSON.
|
| 255 |
`;
|
| 256 |
+
} else if (dayNumber === 11) {
|
| 257 |
+
actionPrompt = `
|
| 258 |
+
🔄 TEAM BUILDING "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
|
| 259 |
+
L'utilisateur a choisi d'approfondir l'équipe (Jour 11).
|
| 260 |
+
|
| 261 |
+
${iterationCount === 1 && !imageUrl ? `Dis STRICTEMENT : "Super ! Envoie-moi la photo de ton premier membre d'équipe avec son nom et son rôle." (Ne dis rien de plus).` : ''}
|
| 262 |
+
|
| 263 |
+
${imageUrl ? `
|
| 264 |
+
📸 ANALYSE VISUELLE (MULTIMODAL) :
|
| 265 |
+
- L'utilisateur a envoyé une photo pour son équipe.
|
| 266 |
+
- VÉRIFICATION OBLIGATOIRE : Tu dois d'abord valider que la photo envoyée ressemble bien à une personne (portrait humain) ou un logo d'équipe, et non à un objet aléatoire. Si ce n'est pas le cas, excuse-toi poliment et demande une vraie photo de la personne.
|
| 267 |
+
- Si la photo est valide, EXTRAIS le nom de la personne et son rôle à partir de son texte ("${userInput}").
|
| 268 |
+
- Mets la photoUrl (qui est "${imageUrl}") et les infos dans ta structure JSON \`teamMembers\`.
|
| 269 |
+
- Dis-lui EXACTEMENT : "Bien reçu la photo de [Nom], je l'ajoute en tant que [Rôle] sur ta Slide Équipe." (Suivi de : "En as-tu un autre ou tapes-tu 2️⃣ SUITE pour passer aux chiffres ?")
|
| 270 |
+
` : (!imageUrl && iterationCount > 1) ? `
|
| 271 |
+
- L'utilisateur vient de répondre sans photo supplémentaire ou pour passer à la suite. Clôture en douceur ou prends ses remarques sur l'équipe en compte.
|
| 272 |
+
` : ''}
|
| 273 |
+
|
| 274 |
+
ATTENTION : Ne pose AUCUNE question qui relève d'une leçon suivante (Reste sur l'Équipe).
|
| 275 |
+
`;
|
| 276 |
} else {
|
| 277 |
actionPrompt = `
|
| 278 |
🔄 ANALYSE ITÉRATIVE "DEEP DIVE" (Tour ${iterationCount}/3) 🔄
|
apps/api/src/services/ai/types.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const SlideSchema = z.object({
|
|
| 36 |
title: z.string().describe("Slide title (max 5 words)"),
|
| 37 |
content: z.array(z.string()).max(4).describe("Narrative storytelling blocks for the slide (15-25 words each). STRICTLY MAX 4 BLOCKS."),
|
| 38 |
notes: z.string().describe("Speaker notes (use empty string if none)"),
|
| 39 |
-
visualType: z.enum(["NONE", "PIE_CHART", "BAR_CHART", "IMAGE", "ICON"]).nullable().optional().describe("Type of visual to add on the right side"),
|
| 40 |
visualData: z.any().nullable().optional().describe("Data for the chart (if any) or image prompt")
|
| 41 |
});
|
| 42 |
export type SlideData = z.infer<typeof SlideSchema>;
|
|
@@ -67,6 +67,12 @@ export const FeedbackSchema = z.object({
|
|
| 67 |
revenueY3: z.string().nullable().optional(),
|
| 68 |
growthRate: z.string().nullable().optional()
|
| 69 |
}).nullable().optional().describe("3-year growth metrics (Day 11)"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
fundingAsk: z.string().nullable().optional().describe("The Ask: amount and purpose (Day 12)"),
|
| 71 |
aiSource: z.string().nullable().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
|
| 72 |
});
|
|
|
|
| 36 |
title: z.string().describe("Slide title (max 5 words)"),
|
| 37 |
content: z.array(z.string()).max(4).describe("Narrative storytelling blocks for the slide (15-25 words each). STRICTLY MAX 4 BLOCKS."),
|
| 38 |
notes: z.string().describe("Speaker notes (use empty string if none)"),
|
| 39 |
+
visualType: z.enum(["NONE", "PIE_CHART", "BAR_CHART", "IMAGE", "ICON", "TEAM"]).nullable().optional().describe("Type of visual to add on the right side"),
|
| 40 |
visualData: z.any().nullable().optional().describe("Data for the chart (if any) or image prompt")
|
| 41 |
});
|
| 42 |
export type SlideData = z.infer<typeof SlideSchema>;
|
|
|
|
| 67 |
revenueY3: z.string().nullable().optional(),
|
| 68 |
growthRate: z.string().nullable().optional()
|
| 69 |
}).nullable().optional().describe("3-year growth metrics (Day 11)"),
|
| 70 |
+
teamMembers: z.array(z.object({
|
| 71 |
+
name: z.string(),
|
| 72 |
+
role: z.string(),
|
| 73 |
+
bio: z.string().optional(),
|
| 74 |
+
photoUrl: z.string().optional()
|
| 75 |
+
})).nullable().optional().describe("List of newly identified team members (Day 11) with their roles and photos"),
|
| 76 |
fundingAsk: z.string().nullable().optional().describe("The Ask: amount and purpose (Day 12)"),
|
| 77 |
aiSource: z.string().nullable().optional().describe("The AI provider used (GEMINI, OPENAI, MOCK)")
|
| 78 |
});
|
apps/api/src/services/renderers/pptx-renderer.ts
CHANGED
|
@@ -89,6 +89,32 @@ export class PptxDeckRenderer implements DocumentRenderer<PitchDeckData> {
|
|
| 89 |
else if (slideData.visualType === 'IMAGE') {
|
| 90 |
slide.addText("📷 [Visual AI Placeholder]", { x: 6.5, y: 2.5, fontSize: 14, color: 'A0AEC0', fontFace: 'Inter' });
|
| 91 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
} catch (vErr) {
|
| 93 |
console.warn(`[RENDERER] Failed to add visual to slide ${index + 1}:`, vErr);
|
| 94 |
}
|
|
|
|
| 89 |
else if (slideData.visualType === 'IMAGE') {
|
| 90 |
slide.addText("📷 [Visual AI Placeholder]", { x: 6.5, y: 2.5, fontSize: 14, color: 'A0AEC0', fontFace: 'Inter' });
|
| 91 |
}
|
| 92 |
+
else if (slideData.visualType === 'TEAM' && Array.isArray(vizData)) {
|
| 93 |
+
const maxMembers = Math.min(vizData.length, 4);
|
| 94 |
+
for (let i = 0; i < maxMembers; i++) {
|
| 95 |
+
const member = vizData[i];
|
| 96 |
+
const row = Math.floor(i / 2);
|
| 97 |
+
const col = i % 2;
|
| 98 |
+
const xP = 5.2 + (col * 2.3);
|
| 99 |
+
const yP = 1.0 + (row * 2.2);
|
| 100 |
+
|
| 101 |
+
if (member.photoUrl && typeof member.photoUrl === 'string' && member.photoUrl.startsWith('http')) {
|
| 102 |
+
slide.addImage({ path: member.photoUrl, x: xP, y: yP, w: 1.8, h: 1.8, sizing: { type: 'cover', w: 1.8, h: 1.8 } });
|
| 103 |
+
} else {
|
| 104 |
+
// Default icon or placeholder shape
|
| 105 |
+
slide.addShape(pres.ShapeType.ellipse, { x: xP + 0.15, y: yP + 0.15, w: 1.5, h: 1.5, fill: { color: 'E2E8F0' } });
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
slide.addText(member.name?.toUpperCase() || "MEMBRE", {
|
| 109 |
+
x: xP, y: yP + 1.85, w: 1.8, h: 0.3,
|
| 110 |
+
fontSize: 12, color: '1B3A57', bold: true, align: 'center', fontFace: 'Montserrat'
|
| 111 |
+
});
|
| 112 |
+
slide.addText(member.role || "Rôle", {
|
| 113 |
+
x: xP, y: yP + 2.15, w: 1.8, h: 0.2,
|
| 114 |
+
fontSize: 10, color: '1C7C54', align: 'center', fontFace: 'Inter'
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
} catch (vErr) {
|
| 119 |
console.warn(`[RENDERER] Failed to add visual to slide ${index + 1}:`, vErr);
|
| 120 |
}
|
apps/whatsapp-worker/src/index.ts
CHANGED
|
@@ -257,12 +257,17 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 257 |
});
|
| 258 |
|
| 259 |
// 🚨 Store Strategy Data in BusinessProfile
|
| 260 |
-
if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk) {
|
| 261 |
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 262 |
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 263 |
if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
|
| 264 |
if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
|
| 265 |
if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
await (prisma as any).businessProfile.upsert({
|
| 268 |
where: { userId },
|
|
@@ -292,12 +297,17 @@ const worker = new Worker('whatsapp-queue', async (job: Job) => {
|
|
| 292 |
});
|
| 293 |
|
| 294 |
// 🚨 Store Strategy Data in BusinessProfile
|
| 295 |
-
if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk) {
|
| 296 |
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 297 |
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 298 |
if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
|
| 299 |
if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
|
| 300 |
if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
await (prisma as any).businessProfile.upsert({
|
| 303 |
where: { userId },
|
|
|
|
| 257 |
});
|
| 258 |
|
| 259 |
// 🚨 Store Strategy Data in BusinessProfile
|
| 260 |
+
if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk || (feedbackData as any)?.teamMembers) {
|
| 261 |
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 262 |
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 263 |
if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
|
| 264 |
if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
|
| 265 |
if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
|
| 266 |
+
if ((feedbackData as any)?.teamMembers && Array.isArray((feedbackData as any).teamMembers)) {
|
| 267 |
+
const existingProfile = await (prisma as any).businessProfile.findUnique({ where: { userId } });
|
| 268 |
+
const existingTeam = Array.isArray(existingProfile?.teamMembers) ? existingProfile.teamMembers : [];
|
| 269 |
+
updatePayload.teamMembers = [...existingTeam, ...(feedbackData as any).teamMembers];
|
| 270 |
+
}
|
| 271 |
|
| 272 |
await (prisma as any).businessProfile.upsert({
|
| 273 |
where: { userId },
|
|
|
|
| 297 |
});
|
| 298 |
|
| 299 |
// 🚨 Store Strategy Data in BusinessProfile
|
| 300 |
+
if (feedbackData?.searchResults || (feedbackData as any)?.competitorList || (feedbackData as any)?.financialProjections || (feedbackData as any)?.fundingAsk || (feedbackData as any)?.teamMembers) {
|
| 301 |
const updatePayload: any = { lastUpdatedFromDay: currentDay };
|
| 302 |
if (feedbackData?.searchResults) updatePayload.marketData = feedbackData.searchResults;
|
| 303 |
if ((feedbackData as any)?.competitorList) updatePayload.competitorList = (feedbackData as any).competitorList;
|
| 304 |
if ((feedbackData as any)?.financialProjections) updatePayload.financialProjections = (feedbackData as any).financialProjections;
|
| 305 |
if ((feedbackData as any)?.fundingAsk) updatePayload.fundingAsk = (feedbackData as any).fundingAsk;
|
| 306 |
+
if ((feedbackData as any)?.teamMembers && Array.isArray((feedbackData as any).teamMembers)) {
|
| 307 |
+
const existingProfile = await (prisma as any).businessProfile.findUnique({ where: { userId } });
|
| 308 |
+
const existingTeam = Array.isArray(existingProfile?.teamMembers) ? existingProfile.teamMembers : [];
|
| 309 |
+
updatePayload.teamMembers = [...existingTeam, ...(feedbackData as any).teamMembers];
|
| 310 |
+
}
|
| 311 |
|
| 312 |
await (prisma as any).businessProfile.upsert({
|
| 313 |
where: { userId },
|
packages/database/prisma/schema.prisma
CHANGED
|
@@ -44,6 +44,7 @@ model BusinessProfile {
|
|
| 44 |
marketData Json? // Stored Google Search results for TAM/SAM/SOM & Competition
|
| 45 |
competitorList Json? // List of rivals found or declared
|
| 46 |
financialProjections Json? // 3-year growth data
|
|
|
|
| 47 |
fundingAsk String? // Amount and purpose
|
| 48 |
lastUpdatedFromDay Int @default(0)
|
| 49 |
createdAt DateTime @default(now())
|
|
|
|
| 44 |
marketData Json? // Stored Google Search results for TAM/SAM/SOM & Competition
|
| 45 |
competitorList Json? // List of rivals found or declared
|
| 46 |
financialProjections Json? // 3-year growth data
|
| 47 |
+
teamMembers Json? // Array of { name, role, bio, photoUrl }
|
| 48 |
fundingAsk String? // Amount and purpose
|
| 49 |
lastUpdatedFromDay Int @default(0)
|
| 50 |
createdAt DateTime @default(now())
|