Skip to content

Commit 756b4d3

Browse files
author
Miriad
committed
Merge branch 'phase1d/sponsor-pipeline' into dev
# Conflicts: # package.json # pnpm-lock.yaml
2 parents b1c1acf + f51817b commit 756b4d3

File tree

11 files changed

+697
-0
lines changed

11 files changed

+697
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
export const fetchCache = 'force-no-store'
2+
3+
import { NextResponse } from 'next/server'
4+
import { sanityWriteClient } from '@/lib/sanity-write-client'
5+
import { generateOutreachEmail } from '@/lib/sponsor/gemini-outreach'
6+
import { sendSponsorEmail } from '@/lib/sponsor/email-service'
7+
import type { SponsorPoolEntry } from '@/lib/sponsor/gemini-outreach'
8+
9+
const MAX_PER_RUN = 5
10+
const COOLDOWN_DAYS = 14
11+
12+
export async function POST(request: Request) {
13+
// Auth: Bearer token check against CRON_SECRET
14+
const authHeader = request.headers.get('authorization')
15+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
16+
console.error('[SPONSOR] Outreach cron: unauthorized request')
17+
return new Response('Unauthorized', { status: 401 })
18+
}
19+
20+
try {
21+
console.log('[SPONSOR] Starting outbound sponsor outreach cron...')
22+
23+
// Calculate the cutoff date for cooldown
24+
const cutoffDate = new Date()
25+
cutoffDate.setDate(cutoffDate.getDate() - COOLDOWN_DAYS)
26+
const cutoffISO = cutoffDate.toISOString()
27+
28+
// Query Sanity for eligible sponsor pool entries
29+
const query = `*[
30+
_type == "sponsorPool"
31+
&& optedOut != true
32+
&& (
33+
!defined(lastContactedAt)
34+
|| lastContactedAt < $cutoffDate
35+
)
36+
] | order(relevanceScore desc) [0...${MAX_PER_RUN - 1}] {
37+
_id,
38+
companyName,
39+
contactName,
40+
contactEmail,
41+
website,
42+
category,
43+
relevanceScore,
44+
optOutToken
45+
}`
46+
47+
const sponsors: SponsorPoolEntry[] = await sanityWriteClient.fetch(query, {
48+
cutoffDate: cutoffISO,
49+
})
50+
51+
console.log(`[SPONSOR] Found ${sponsors.length} eligible sponsors for outreach`)
52+
53+
if (sponsors.length === 0) {
54+
return NextResponse.json({
55+
success: true,
56+
message: 'No eligible sponsors for outreach',
57+
processed: 0,
58+
})
59+
}
60+
61+
const results: Array<{ companyName: string; success: boolean; error?: string }> = []
62+
63+
for (const sponsor of sponsors) {
64+
try {
65+
// Generate personalized outreach email
66+
const email = await generateOutreachEmail(sponsor)
67+
68+
// Send the email (stubbed)
69+
const sendResult = await sendSponsorEmail(
70+
sponsor.contactEmail,
71+
email.subject,
72+
email.body
73+
)
74+
75+
if (sendResult.success) {
76+
// Update lastContactedAt on the sponsor pool entry
77+
await sanityWriteClient
78+
.patch(sponsor._id)
79+
.set({ lastContactedAt: new Date().toISOString() })
80+
.commit()
81+
82+
// Create a sponsorLead with source='outbound'
83+
await sanityWriteClient.create({
84+
_type: 'sponsorLead',
85+
companyName: sponsor.companyName,
86+
contactName: sponsor.contactName,
87+
contactEmail: sponsor.contactEmail,
88+
source: 'outbound',
89+
status: 'contacted',
90+
threadId: crypto.randomUUID(),
91+
lastEmailAt: new Date().toISOString(),
92+
})
93+
94+
results.push({ companyName: sponsor.companyName, success: true })
95+
console.log(`[SPONSOR] Outreach sent to: ${sponsor.companyName}`)
96+
} else {
97+
results.push({
98+
companyName: sponsor.companyName,
99+
success: false,
100+
error: 'Email send failed',
101+
})
102+
}
103+
} catch (error) {
104+
const errorMsg = error instanceof Error ? error.message : String(error)
105+
console.error(`[SPONSOR] Outreach failed for ${sponsor.companyName}:`, errorMsg)
106+
results.push({ companyName: sponsor.companyName, success: false, error: errorMsg })
107+
}
108+
}
109+
110+
const successCount = results.filter((r) => r.success).length
111+
console.log(`[SPONSOR] Outreach cron complete: ${successCount}/${results.length} successful`)
112+
113+
return NextResponse.json({
114+
success: true,
115+
processed: results.length,
116+
successful: successCount,
117+
results,
118+
})
119+
} catch (error) {
120+
console.error('[SPONSOR] Outreach cron error:', error)
121+
return NextResponse.json(
122+
{ error: 'Internal server error' },
123+
{ status: 500 }
124+
)
125+
}
126+
}

app/api/sponsor/opt-out/route.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { sanityWriteClient } from '@/lib/sanity-write-client'
2+
3+
export async function GET(request: Request) {
4+
const { searchParams } = new URL(request.url)
5+
const token = searchParams.get('token')
6+
7+
if (!token) {
8+
return new Response(
9+
renderHtml('Invalid Request', 'No opt-out token provided.'),
10+
{ status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
11+
)
12+
}
13+
14+
try {
15+
// Query Sanity for sponsorPool entry with matching optOutToken
16+
const query = '*[_type == "sponsorPool" && optOutToken == $token][0]{ _id, companyName }'
17+
const params = { token } as Record<string, string>
18+
const sponsor = await sanityWriteClient.fetch(
19+
query,
20+
params
21+
) as { _id: string; companyName: string } | null
22+
23+
if (!sponsor) {
24+
console.warn('[SPONSOR] Opt-out: invalid token:', token)
25+
return new Response(
26+
renderHtml('Not Found', 'This opt-out link is invalid or has already been processed.'),
27+
{ status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
28+
)
29+
}
30+
31+
// Set optedOut = true
32+
await sanityWriteClient.patch(sponsor._id).set({ optedOut: true }).commit()
33+
34+
console.log('[SPONSOR] Opt-out processed for:', sponsor.companyName)
35+
36+
return new Response(
37+
renderHtml(
38+
'Opted Out Successfully',
39+
`You have been successfully removed from CodingCat.dev sponsor outreach. You will no longer receive emails from us regarding sponsorship opportunities.`
40+
),
41+
{ status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
42+
)
43+
} catch (error) {
44+
console.error('[SPONSOR] Opt-out error:', error)
45+
return new Response(
46+
renderHtml('Error', 'Something went wrong processing your opt-out. Please try again later.'),
47+
{ status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
48+
)
49+
}
50+
}
51+
52+
function renderHtml(title: string, message: string): string {
53+
return `<!DOCTYPE html>
54+
<html lang="en">
55+
<head>
56+
<meta charset="UTF-8">
57+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
58+
<title>${title} — CodingCat.dev</title>
59+
<style>
60+
body {
61+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
62+
display: flex;
63+
justify-content: center;
64+
align-items: center;
65+
min-height: 100vh;
66+
margin: 0;
67+
background: #f5f5f5;
68+
color: #333;
69+
}
70+
.container {
71+
text-align: center;
72+
padding: 2rem;
73+
max-width: 500px;
74+
background: white;
75+
border-radius: 12px;
76+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
77+
}
78+
h1 { color: #7c3aed; margin-bottom: 1rem; }
79+
p { line-height: 1.6; color: #666; }
80+
</style>
81+
</head>
82+
<body>
83+
<div class="container">
84+
<h1>${title}</h1>
85+
<p>${message}</p>
86+
</div>
87+
</body>
88+
</html>`
89+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { sanityWriteClient } from '@/lib/sanity-write-client'
4+
import { extractSponsorIntent } from '@/lib/sponsor/gemini-intent'
5+
import { sendSponsorEmail } from '@/lib/sponsor/email-service'
6+
7+
const RATE_CARD = `
8+
CodingCat.dev Sponsorship Tiers:
9+
- Dedicated Video ($4,000) — Full dedicated video about your product
10+
- Integrated Mid-Roll Ad ($1,800) — Mid-roll advertisement in our videos
11+
- Quick Shout-Out ($900) — Brief mention in our videos
12+
- Blog Post / Newsletter ($500) — Featured in our blog or newsletter
13+
- Video Series (Custom) — Multi-video partnership series
14+
15+
Learn more: https://codingcat.dev/sponsorships
16+
`.trim()
17+
18+
const inboundSchema = z.object({
19+
fullName: z.string().min(1, 'Full name is required'),
20+
email: z.string().email('Valid email is required'),
21+
company: z.string().optional().default(''),
22+
message: z.string().optional().default(''),
23+
tiers: z.array(z.string()).optional().default([]),
24+
})
25+
26+
export async function POST(request: Request) {
27+
// Verify webhook secret
28+
const webhookSecret = request.headers.get('x-webhook-secret')
29+
if (!webhookSecret || webhookSecret !== process.env.SPONSOR_WEBHOOK_SECRET) {
30+
console.error('[SPONSOR] Inbound webhook: unauthorized request')
31+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
32+
}
33+
34+
try {
35+
const body = await request.json()
36+
const parsed = inboundSchema.safeParse(body)
37+
38+
if (!parsed.success) {
39+
console.error('[SPONSOR] Inbound webhook: validation failed', parsed.error.issues)
40+
return NextResponse.json(
41+
{ error: 'Validation failed', details: parsed.error.issues },
42+
{ status: 400 }
43+
)
44+
}
45+
46+
const { fullName, email, company, message, tiers } = parsed.data
47+
48+
// Extract intent using Gemini
49+
const combinedMessage = [
50+
company ? `Company: ${company}` : '',
51+
`From: ${fullName} (${email})`,
52+
tiers.length ? `Interested tiers: ${tiers.join(', ')}` : '',
53+
message,
54+
]
55+
.filter(Boolean)
56+
.join('\n')
57+
58+
console.log('[SPONSOR] Processing inbound inquiry from:', email)
59+
60+
const intent = await extractSponsorIntent(combinedMessage)
61+
62+
// Create sponsorLead in Sanity
63+
const leadDoc = {
64+
_type: 'sponsorLead',
65+
companyName: intent.companyName || company || 'Unknown',
66+
contactName: intent.contactName || fullName,
67+
contactEmail: email,
68+
source: 'inbound',
69+
status: 'new',
70+
intent: intent.intent,
71+
rateCard: tiers.length > 0 ? tiers.join(', ') : intent.suggestedTiers.join(', '),
72+
threadId: crypto.randomUUID(),
73+
lastEmailAt: new Date().toISOString(),
74+
}
75+
76+
const created = await sanityWriteClient.create(leadDoc)
77+
console.log('[SPONSOR] Created sponsor lead:', created._id)
78+
79+
// Send auto-reply with rate card (stubbed)
80+
await sendSponsorEmail(
81+
email,
82+
`Thanks for your interest in sponsoring CodingCat.dev!`,
83+
`Hi ${fullName},\n\nThanks for reaching out about sponsoring CodingCat.dev! I'm excited to explore how we can work together.\n\nHere's our current rate card:\n\n${RATE_CARD}\n\nI'll review your inquiry and get back to you within 48 hours.\n\nBest,\nAlex Patterson\nCodingCat.dev`
84+
)
85+
86+
return NextResponse.json({
87+
success: true,
88+
leadId: created._id,
89+
message: 'Sponsor inquiry received and processed',
90+
})
91+
} catch (error) {
92+
console.error('[SPONSOR] Inbound webhook error:', error)
93+
return NextResponse.json(
94+
{ error: 'Internal server error' },
95+
{ status: 500 }
96+
)
97+
}
98+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { NextResponse } from 'next/server'
2+
3+
/**
4+
* Stripe webhook handler for sponsor invoices.
5+
*
6+
* STUBBED — needs STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET
7+
*
8+
* Will handle:
9+
* - invoice.paid → update sponsorLead status to 'paid', assign to next video
10+
* - invoice.payment_failed → update sponsorLead stripePaymentStatus to 'failed'
11+
*
12+
* TODO: Wire up Stripe webhook verification and event handling
13+
*/
14+
export async function POST(request: Request) {
15+
try {
16+
const body = await request.text()
17+
18+
// TODO: Verify Stripe webhook signature
19+
// const sig = request.headers.get('stripe-signature')
20+
// const event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!)
21+
22+
console.log('[SPONSOR] Stripe webhook received (stubbed):', {
23+
contentLength: body.length,
24+
timestamp: new Date().toISOString(),
25+
})
26+
27+
// TODO: Handle events
28+
// switch (event.type) {
29+
// case 'invoice.paid': {
30+
// const invoice = event.data.object
31+
// // Find sponsorLead by stripeInvoiceId
32+
// // Update status to 'paid'
33+
// // Update stripePaymentStatus to 'paid'
34+
// // Assign to next available video
35+
// break
36+
// }
37+
// case 'invoice.payment_failed': {
38+
// const invoice = event.data.object
39+
// // Find sponsorLead by stripeInvoiceId
40+
// // Update stripePaymentStatus to 'failed'
41+
// break
42+
// }
43+
// }
44+
45+
return NextResponse.json({ received: true })
46+
} catch (error) {
47+
console.error('[SPONSOR] Stripe webhook error:', error)
48+
return NextResponse.json(
49+
{ error: 'Webhook processing failed' },
50+
{ status: 500 }
51+
)
52+
}
53+
}

0 commit comments

Comments
 (0)