Skip to content

Commit 52dac17

Browse files
committed
feat: Add GitHub integration for automated campaign creation
- ✨ Campaign creation wizard with GitHub and manual options - πŸ”§ GitHub repository content extraction service - πŸ€– AI-powered campaign generation from README/docs - πŸ“Š Comprehensive service provider auto-generation - 🎨 Modern wizard UI with step-by-step guidance - οΏ½οΏ½ GitHub token setup with clear instructions - πŸ—οΈ Database schema for GitHub connections - πŸš€ Complete API endpoints for integration - 🎯 Auto-generation of milestones and pledge tiers - πŸ“ Service provider profiles from domain research Includes: - Campaign creation wizard (GitHub vs Manual) - GitHub repository analysis and content extraction - OpenAI structured output schema fixes - Service provider auto-generation via Perplexity - Comprehensive documentation and error handling - Production-ready build configuration
1 parent aefce5c commit 52dac17

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+7153
-847
lines changed

β€Žapp/admin/campaigns/[id]/page.tsxβ€Ž

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,10 @@ export default async function AdminCampaignDetail({
216216
{campaign.description && (
217217
<div className="mt-6">
218218
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Description</h3>
219-
<div className="prose dark:prose-invert max-w-none">
220-
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
221-
{campaign.description}
222-
</p>
223-
</div>
219+
<div
220+
className="prose max-w-none"
221+
dangerouslySetInnerHTML={{ __html: campaign.description }}
222+
/>
224223
</div>
225224
)}
226225
</div>

β€Žapp/api/campaigns/[id]/generate-image/route.tsβ€Ž

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { auth } from '@/lib/auth';
33
import { prisma } from '@/lib/db';
4-
import { ImageGenerationService } from '@/lib/services/ImageGenerationService';
4+
import { ImageLibraryService } from '@/lib/services/ImageLibraryService';
55
import { AIClient } from '@/lib/aiClient';
66
import { AIError } from '@/lib/aiService';
77

@@ -45,24 +45,28 @@ export async function POST(
4545
}, { status: 500 });
4646
}
4747

48-
// Generate image using the new service
49-
const service = new ImageGenerationService();
50-
const result = await service.generateCampaignImage({
51-
id: campaign.id,
52-
title: campaign.title,
53-
summary: campaign.summary || undefined,
54-
description: campaign.description || undefined
48+
// Generate and store image using the library service
49+
const service = new ImageLibraryService();
50+
const result = await service.generateAndStoreImage({
51+
prompt: `Professional hero image for "${campaign.title}". ${campaign.summary || ''}`,
52+
userId: session.user.id,
53+
organizationId: campaign.organizationId || undefined,
54+
campaignTitle: campaign.title,
55+
theme: 'technology',
56+
tags: ['campaign', 'hero', 'professional'],
57+
isPublic: false
5558
});
5659

57-
// Update campaign with new image path
60+
// Update campaign with new image URL
5861
await prisma.campaign.update({
5962
where: { id: campaign.id },
60-
data: { image: result.data.imagePath }
63+
data: { image: result.data.blobUrl }
6164
});
6265

6366
return NextResponse.json({
6467
success: true,
65-
imagePath: result.data.imagePath,
68+
imagePath: result.data.blobUrl,
69+
imageUrl: result.data.blobUrl,
6670
prompt: result.data.prompt,
6771
message: 'Image generated successfully!'
6872
}, {

β€Žapp/api/campaigns/[id]/route.tsβ€Ž

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
5959
sectors,
6060
requireBackerAccount,
6161
onlyBackersComment,
62+
milestones,
63+
stretchGoals,
64+
priceTiers,
6265
} = body;
6366

6467
// Get existing campaign
@@ -103,6 +106,65 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
103106
if (onlyBackersComment !== undefined) updateData.onlyBackersComment = onlyBackersComment;
104107
}
105108

109+
// Handle milestones and stretch goals updates
110+
if (milestones !== undefined && (existingCampaign.status === 'draft' || isAdmin)) {
111+
// Delete existing milestones and create new ones
112+
await prisma.milestone.deleteMany({
113+
where: { campaignId: resolvedParams.id }
114+
});
115+
116+
if (milestones.length > 0) {
117+
await prisma.milestone.createMany({
118+
data: milestones.map((milestone: any) => ({
119+
campaignId: resolvedParams.id,
120+
name: milestone.name,
121+
pct: milestone.pct,
122+
acceptance: milestone.acceptance,
123+
status: 'pending'
124+
}))
125+
});
126+
}
127+
}
128+
129+
if (stretchGoals !== undefined && (existingCampaign.status === 'draft' || isAdmin)) {
130+
// Delete existing stretch goals and create new ones
131+
await prisma.stretchGoal.deleteMany({
132+
where: { campaignId: resolvedParams.id }
133+
});
134+
135+
if (stretchGoals.length > 0) {
136+
await prisma.stretchGoal.createMany({
137+
data: stretchGoals.map((goal: any, index: number) => ({
138+
campaignId: resolvedParams.id,
139+
title: goal.title,
140+
description: goal.description,
141+
targetDollars: goal.targetDollars,
142+
order: goal.order || index + 1
143+
}))
144+
});
145+
}
146+
}
147+
148+
if (priceTiers !== undefined && (existingCampaign.status === 'draft' || isAdmin)) {
149+
// Delete existing price tiers and create new ones
150+
await prisma.pledgeTier.deleteMany({
151+
where: { campaignId: resolvedParams.id }
152+
});
153+
154+
if (priceTiers.length > 0) {
155+
await prisma.pledgeTier.createMany({
156+
data: priceTiers.map((tier: any, index: number) => ({
157+
campaignId: resolvedParams.id,
158+
title: tier.title,
159+
description: tier.description,
160+
amountDollars: tier.amountDollars,
161+
benefits: JSON.stringify(tier.benefits || []),
162+
order: tier.order || index + 1
163+
}))
164+
});
165+
}
166+
}
167+
106168
// Update the campaign
107169
const updatedCampaign = await prisma.campaign.update({
108170
where: { id: resolvedParams.id },
@@ -113,7 +175,10 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
113175
},
114176
organization: {
115177
select: { id: true, name: true }
116-
}
178+
},
179+
milestones: true,
180+
stretchGoals: { orderBy: { order: 'asc' } },
181+
pledgeTiers: { orderBy: { order: 'asc' } }
117182
}
118183
});
119184

β€Žapp/api/campaigns/auto-generate-image/route.tsβ€Ž

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { auth } from '@/lib/auth';
33
import { prisma } from '@/lib/db';
4-
import { ImageGenerationService } from '@/lib/services/ImageGenerationService';
4+
import { ImageLibraryService } from '@/lib/services/ImageLibraryService';
55
import { AIClient } from '@/lib/aiClient';
66
import { AIError } from '@/lib/aiService';
77

@@ -58,24 +58,28 @@ export async function POST(request: NextRequest) {
5858
});
5959
}
6060

61-
// Generate image using the new service
62-
const service = new ImageGenerationService();
63-
const result = await service.generateCampaignImage({
64-
id: campaign.id,
65-
title: campaign.title,
66-
summary: campaign.summary || undefined,
67-
description: campaign.description || undefined
61+
// Generate and store image using the library service
62+
const service = new ImageLibraryService();
63+
const result = await service.generateAndStoreImage({
64+
prompt: `Professional hero image for "${campaign.title}". ${campaign.summary || ''}`,
65+
userId: campaign.makerId,
66+
organizationId: campaign.organizationId || undefined,
67+
campaignTitle: campaign.title,
68+
theme: 'technology',
69+
tags: ['campaign', 'hero', 'auto-generated'],
70+
isPublic: false
6871
});
6972

70-
// Update campaign with new image path
73+
// Update campaign with new image URL
7174
await prisma.campaign.update({
7275
where: { id: campaign.id },
73-
data: { image: result.data.imagePath }
76+
data: { image: result.data.blobUrl }
7477
});
7578

7679
return NextResponse.json({
7780
success: true,
78-
imagePath: result.data.imagePath,
81+
imagePath: result.data.blobUrl,
82+
imageUrl: result.data.blobUrl,
7983
prompt: result.data.prompt,
8084
message: 'Image generated successfully'
8185
}, {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { prisma } from "@/lib/db";
3+
import { auth } from "@/lib/auth";
4+
import { z } from "zod";
5+
import GitHubService from "@/lib/services/GitHubService";
6+
import CampaignGenerationService from "@/lib/services/CampaignGenerationService";
7+
8+
const GenerateFromRepoSchema = z.object({
9+
repoUrl: z.string().url("Valid repository URL is required"),
10+
userPrompt: z.string().optional(),
11+
autoCreate: z.boolean().default(false).describe("Whether to automatically create the campaign or just return the generated content")
12+
});
13+
14+
export async function POST(req: NextRequest) {
15+
try {
16+
const session = await auth();
17+
if (!session?.user?.id) {
18+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
19+
}
20+
21+
const body = await req.json();
22+
const { repoUrl, userPrompt, autoCreate } = GenerateFromRepoSchema.parse(body);
23+
24+
// Get user's GitHub connection
25+
const githubConnection = await prisma.gitHubConnection.findUnique({
26+
where: { userId: session.user.id }
27+
});
28+
29+
// Initialize GitHub service with token if available
30+
const githubService = new GitHubService(githubConnection?.githubToken);
31+
32+
// Extract repository content
33+
console.log(`πŸ” Extracting content from repository: ${repoUrl}`);
34+
const repoContent = await githubService.extractRepositoryContent(repoUrl);
35+
36+
if (!repoContent.hasDocumentation) {
37+
return NextResponse.json({
38+
error: 'Repository has no README or documentation to generate campaign from',
39+
suggestion: 'Add a README.md file to your repository with project description'
40+
}, { status: 400 });
41+
}
42+
43+
// Generate campaign content using AI
44+
console.log(`πŸ€– Generating campaign content for: ${repoContent.repository.name}`);
45+
const campaignGenerationService = new CampaignGenerationService();
46+
47+
const generationResult = await campaignGenerationService.generateCampaign({
48+
repository: repoContent.repository,
49+
readmeContent: repoContent.readmeContent,
50+
docsContent: repoContent.docsContent,
51+
userPrompt: userPrompt || ''
52+
});
53+
54+
const generatedCampaign = generationResult.data;
55+
56+
// If autoCreate is true, create the campaign in the database
57+
if (autoCreate) {
58+
console.log(`πŸ“ Auto-creating campaign: ${generatedCampaign.title}`);
59+
60+
const campaign = await prisma.campaign.create({
61+
data: {
62+
makerId: session.user.id,
63+
title: generatedCampaign.title,
64+
summary: generatedCampaign.summary,
65+
description: generatedCampaign.description,
66+
fundingGoalDollars: generatedCampaign.fundingGoalDollars,
67+
repoUrl: repoUrl,
68+
sectors: generatedCampaign.sectors,
69+
deployModes: generatedCampaign.deployModes,
70+
status: 'draft'
71+
},
72+
include: {
73+
maker: {
74+
select: {
75+
id: true,
76+
name: true,
77+
email: true
78+
}
79+
}
80+
}
81+
});
82+
83+
// Create milestones
84+
const milestones = await Promise.all(
85+
generatedCampaign.milestones.map(milestone =>
86+
prisma.milestone.create({
87+
data: {
88+
campaignId: campaign.id,
89+
name: milestone.name,
90+
pct: milestone.pct,
91+
acceptance: milestone.acceptance
92+
}
93+
})
94+
)
95+
);
96+
97+
// Create pledge tiers
98+
const pledgeTiers = await Promise.all(
99+
generatedCampaign.pledgeTiers.map(tier =>
100+
prisma.pledgeTier.create({
101+
data: {
102+
campaignId: campaign.id,
103+
title: tier.title,
104+
description: tier.description,
105+
amountDollars: tier.amountDollars,
106+
order: tier.order
107+
}
108+
})
109+
)
110+
);
111+
112+
return NextResponse.json({
113+
success: true,
114+
campaign: {
115+
...campaign,
116+
milestones,
117+
pledgeTiers
118+
},
119+
generated: generatedCampaign,
120+
repository: repoContent.repository
121+
}, { status: 201 });
122+
}
123+
124+
// Return generated content without creating campaign
125+
return NextResponse.json({
126+
success: true,
127+
generated: generatedCampaign,
128+
repository: repoContent.repository,
129+
hasDocumentation: repoContent.hasDocumentation
130+
});
131+
132+
} catch (error) {
133+
if (error instanceof z.ZodError) {
134+
return NextResponse.json({
135+
error: 'Invalid input data',
136+
details: error.errors
137+
}, { status: 400 });
138+
}
139+
140+
console.error('Error generating campaign from repository:', error);
141+
142+
// Handle specific GitHub errors
143+
if (error instanceof Error) {
144+
if (error.message.includes('Repository not found')) {
145+
return NextResponse.json({
146+
error: 'Repository not found or not accessible. Please check the URL and ensure the repository is public or you have access.'
147+
}, { status: 404 });
148+
}
149+
150+
if (error.message.includes('GitHub API error')) {
151+
return NextResponse.json({
152+
error: 'GitHub API error. Please try again later or check your GitHub token.'
153+
}, { status: 502 });
154+
}
155+
}
156+
157+
return NextResponse.json({
158+
error: 'Internal server error'
159+
}, { status: 500 });
160+
}
161+
}

0 commit comments

Comments
Β (0)