Skip to content

Commit 35188f6

Browse files
committed
Add petition management and analytics routes
Implemented new routes for managing and analyzing petitions. Added interfaces for daily and weekly petition summary notifications. Included detailed petition visualization features and admin controls. Took 33 minutes
1 parent 2140d4e commit 35188f6

File tree

34 files changed

+2801
-0
lines changed

34 files changed

+2801
-0
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextResponse } from 'next/server'
2+
import { sendDailySummaries, sendWeeklySummaries } from '@/lib/notifications/petition-summary'
3+
4+
export const runtime = 'edge'
5+
6+
export async function GET(request: Request) {
7+
try {
8+
const { searchParams } = new URL(request.url)
9+
const type = searchParams.get('type')
10+
11+
if (type === 'daily') {
12+
await sendDailySummaries()
13+
} else if (type === 'weekly') {
14+
await sendWeeklySummaries()
15+
}
16+
17+
return NextResponse.json({ success: true })
18+
} catch (error) {
19+
console.error('Cron job failed:', error)
20+
return NextResponse.json({ error: 'Failed to process summaries' }, { status: 500 })
21+
}
22+
}

app/petitions/[id]/admin/page.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getServerSession } from "next-auth/next"
2+
import { prisma } from "@/lib/prisma"
3+
import { redirect } from "next/navigation"
4+
import { PetitionAdminControls } from "../../components/PetitionAdminControls"
5+
6+
export default async function PetitionAdminPage({ params }: { params: { id: string } }) {
7+
const session = await getServerSession()
8+
if (!session?.user) {
9+
redirect('/api/auth/signin')
10+
}
11+
12+
const petition = await prisma.petition.findUnique({
13+
where: { id: params.id },
14+
include: {
15+
_count: { select: { signatures: true } },
16+
milestones: true,
17+
statusUpdates: {
18+
orderBy: { createdAt: 'desc' }
19+
}
20+
}
21+
})
22+
23+
if (!petition || petition.creatorId !== session.user.id) {
24+
redirect('/petitions')
25+
}
26+
27+
return (
28+
<div className="container mx-auto px-4 py-8">
29+
<h1 className="text-3xl font-bold mb-8">Manage Petition</h1>
30+
<PetitionAdminControls petition={petition} />
31+
</div>
32+
)
33+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
'use client'
2+
3+
import { getServerSession } from "next-auth/next"
4+
import { prisma } from "@/lib/prisma"
5+
import { redirect } from "next/navigation"
6+
import { Card } from "@/components/ui/card"
7+
import { ErrorBoundary } from "react-error-boundary"
8+
import {
9+
LineChart,
10+
Line,
11+
XAxis,
12+
YAxis,
13+
CartesianGrid,
14+
Tooltip,
15+
ResponsiveContainer,
16+
PieChart,
17+
Pie,
18+
Cell
19+
} from 'recharts'
20+
21+
function ErrorFallback({ error, resetErrorBoundary }: {
22+
error: Error
23+
resetErrorBoundary: () => void
24+
}) {
25+
return (
26+
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
27+
<h2 className="text-red-800 font-medium">Something went wrong:</h2>
28+
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
29+
<button
30+
onClick={resetErrorBoundary}
31+
className="mt-4 px-4 py-2 bg-red-100 text-red-800 rounded hover:bg-red-200"
32+
>
33+
Try again
34+
</button>
35+
</div>
36+
)
37+
}
38+
39+
function Chart({ children }: { children: React.ReactNode }) {
40+
return (
41+
<ErrorBoundary
42+
FallbackComponent={ErrorFallback}
43+
onReset={() => {
44+
// Reset any state that might have caused the error
45+
}}
46+
>
47+
<div className="h-[400px] relative">
48+
{children}
49+
</div>
50+
</ErrorBoundary>
51+
)
52+
}
53+
54+
export default async function PetitionAnalyticsPage({ params }: { params: { id: string } }) {
55+
const session = await getServerSession()
56+
if (!session?.user) {
57+
redirect('/api/auth/signin')
58+
}
59+
60+
const petition = await prisma.petition.findUnique({
61+
where: { id: params.id },
62+
include: {
63+
signatures: {
64+
orderBy: { signedAt: 'asc' },
65+
select: {
66+
signedAt: true,
67+
referralSource: true,
68+
referrer: {
69+
select: { name: true }
70+
}
71+
}
72+
},
73+
_count: {
74+
select: { signatures: true }
75+
}
76+
}
77+
})
78+
79+
if (!petition || petition.creatorId !== session.user.id) {
80+
redirect('/petitions')
81+
}
82+
83+
// Calculate signatures by day
84+
const signaturesByDay = petition.signatures.reduce((acc: Record<string, number>, sig) => {
85+
const day = sig.signedAt.toISOString().split('T')[0]
86+
acc[day] = (acc[day] || 0) + 1
87+
return acc
88+
}, {})
89+
90+
const dailyData = Object.entries(signaturesByDay).map(([date, count]) => ({
91+
date,
92+
signatures: count
93+
}))
94+
95+
// Calculate referral sources
96+
const referralSources = petition.signatures.reduce((acc: Record<string, number>, sig) => {
97+
const source = sig.referralSource?.toLowerCase() || 'direct'
98+
acc[source] = (acc[source] || 0) + 1
99+
return acc
100+
}, {})
101+
102+
const referralData = Object.entries(referralSources).map(([source, count]) => ({
103+
name: source,
104+
value: count
105+
}))
106+
107+
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042']
108+
109+
return (
110+
<div className="container mx-auto px-4 py-8">
111+
<h1 className="text-3xl font-bold mb-8">Petition Analytics</h1>
112+
113+
<div className="grid gap-8">
114+
<Card className="p-6">
115+
<h2 className="text-xl font-semibold mb-4">Signatures Over Time</h2>
116+
<Chart>
117+
<ResponsiveContainer width="100%" height="100%">
118+
<LineChart data={dailyData}>
119+
<CartesianGrid strokeDasharray="3 3" />
120+
<XAxis dataKey="date" />
121+
<YAxis />
122+
<Tooltip />
123+
<Line type="monotone" dataKey="signatures" stroke="#8884d8" />
124+
</LineChart>
125+
</ResponsiveContainer>
126+
</Chart>
127+
</Card>
128+
129+
<Card className="p-6">
130+
<h2 className="text-xl font-semibold mb-4">Referral Sources</h2>
131+
<Chart>
132+
<ResponsiveContainer width="100%" height="100%">
133+
<PieChart>
134+
<Pie
135+
data={referralData}
136+
cx="50%"
137+
cy="50%"
138+
labelLine={false}
139+
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
140+
outerRadius={150}
141+
fill="#8884d8"
142+
dataKey="value"
143+
>
144+
{referralData.map((entry, index) => (
145+
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
146+
))}
147+
</Pie>
148+
<Tooltip />
149+
</PieChart>
150+
</ResponsiveContainer>
151+
</Chart>
152+
</Card>
153+
</div>
154+
</div>
155+
)
156+
}

app/petitions/[id]/page.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { getServerSession } from "next-auth/next"
2+
import { authOptions } from "@/lib/auth"
3+
import { prisma } from "@/lib/prisma"
4+
import { SignPetitionButton } from "../components/SignPetitionButton"
5+
import { ShareButtons } from "../components/ShareButtons"
6+
import { Comments } from "../components/Comments"
7+
import { FollowButton } from "../components/FollowButton"
8+
import { notFound } from "next/navigation"
9+
import { RepresentativeMessaging } from "../components/RepresentativeMessaging"
10+
import { Suspense } from 'react'
11+
import { LoadingSpinner } from '@/components/ui/loading-spinner'
12+
import { Metadata } from 'next'
13+
import { MDXRemote } from 'next-mdx-remote/rsc'
14+
15+
const defaultMessageTemplate = `Dear [REP_NAME],
16+
17+
As your constituent, I am writing to bring your attention to [PETITION_TITLE]. This issue is important to me and many other constituents in your district.
18+
19+
I urge you to take action on this matter.
20+
21+
Sincerely,
22+
[NAME]`
23+
24+
const defaultCallScript = `Hello, my name is [NAME] and I'm a constituent calling about [PETITION_TITLE].
25+
26+
I'm calling to urge [REP_NAME] to take action on this important issue.
27+
28+
This matters to me because it affects our community directly.
29+
30+
Thank you for your time and for passing along my message.`
31+
32+
export default async function PetitionPage({ params }: { params: { id: string } }) {
33+
const session = await getServerSession(authOptions)
34+
const petition = await prisma.petition.findUnique({
35+
where: { id: params.id },
36+
include: {
37+
_count: { select: { signatures: true } },
38+
creator: { select: { name: true } },
39+
comments: {
40+
orderBy: { createdAt: 'desc' },
41+
include: {
42+
user: {
43+
select: { name: true, image: true }
44+
}
45+
}
46+
}
47+
}
48+
})
49+
50+
if (!petition) {
51+
notFound()
52+
}
53+
54+
const [hasUserSigned, isFollowing] = await Promise.all([
55+
session?.user?.id ? prisma.petitionSignature.findUnique({
56+
where: {
57+
petitionId_userId: {
58+
petitionId: petition.id,
59+
userId: session.user.id,
60+
}
61+
}
62+
}) : null,
63+
session?.user?.id ? prisma.petitionFollow.findUnique({
64+
where: {
65+
petitionId_userId: {
66+
petitionId: petition.id,
67+
userId: session.user.id,
68+
}
69+
}
70+
}) : null
71+
])
72+
73+
return (
74+
<div className="container mx-auto px-4 py-8">
75+
<div className="max-w-3xl mx-auto">
76+
{petition.imageUrl && (
77+
<img
78+
src={petition.imageUrl}
79+
alt={petition.title}
80+
className="w-full h-64 object-cover rounded-lg mb-6"
81+
/>
82+
)}
83+
84+
<h1 className="text-4xl font-bold mb-4">{petition.title}</h1>
85+
<div className="flex items-center gap-4 text-gray-600 mb-8">
86+
<span>Created by {petition.creator.name}</span>
87+
<span>{petition._count.signatures} signatures</span>
88+
</div>
89+
90+
<div className="prose max-w-none dark:prose-invert mb-8">
91+
<MDXRemote source={petition.content} />
92+
</div>
93+
94+
<div className="flex items-center gap-4 mb-8">
95+
<SignPetitionButton
96+
petitionId={petition.id}
97+
hasSigned={!!hasUserSigned}
98+
/>
99+
<FollowButton
100+
petitionId={petition.id}
101+
initialFollowing={!!isFollowing}
102+
/>
103+
</div>
104+
105+
{hasUserSigned && (
106+
<>
107+
<ShareButtons
108+
petitionId={petition.id}
109+
userId={session!.user.id}
110+
/>
111+
112+
<div className="mt-8">
113+
<RepresentativeMessaging
114+
petitionTitle={petition.title}
115+
defaultMessageTemplate={petition.messageTemplate || defaultMessageTemplate}
116+
defaultCallScript={petition.callScript || defaultCallScript}
117+
/>
118+
</div>
119+
</>
120+
)}
121+
122+
<Suspense fallback={<LoadingSpinner />}>
123+
<Comments
124+
petitionId={petition.id}
125+
initialComments={petition.comments}
126+
/>
127+
</Suspense>
128+
</div>
129+
</div>
130+
)
131+
}
132+
133+
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
134+
const petition = await prisma.petition.findUnique({
135+
where: { id: params.id },
136+
select: {
137+
title: true,
138+
summary: true
139+
}
140+
})
141+
142+
if (!petition) return {}
143+
144+
return {
145+
title: petition.title,
146+
description: petition.summary,
147+
openGraph: {
148+
title: petition.title,
149+
description: petition.summary,
150+
type: 'website'
151+
}
152+
}
153+
}

0 commit comments

Comments
 (0)