Skip to content

Commit

Permalink
Add petition management and analytics routes
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mikepsinn committed Nov 20, 2024
1 parent 2140d4e commit 35188f6
Show file tree
Hide file tree
Showing 34 changed files with 2,801 additions and 0 deletions.
22 changes: 22 additions & 0 deletions app/api/cron/petition-summaries/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { sendDailySummaries, sendWeeklySummaries } from '@/lib/notifications/petition-summary'

export const runtime = 'edge'

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const type = searchParams.get('type')

if (type === 'daily') {
await sendDailySummaries()
} else if (type === 'weekly') {
await sendWeeklySummaries()
}

return NextResponse.json({ success: true })
} catch (error) {
console.error('Cron job failed:', error)
return NextResponse.json({ error: 'Failed to process summaries' }, { status: 500 })
}
}
33 changes: 33 additions & 0 deletions app/petitions/[id]/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { getServerSession } from "next-auth/next"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { PetitionAdminControls } from "../../components/PetitionAdminControls"

export default async function PetitionAdminPage({ params }: { params: { id: string } }) {
const session = await getServerSession()
if (!session?.user) {
redirect('/api/auth/signin')
}

const petition = await prisma.petition.findUnique({
where: { id: params.id },
include: {
_count: { select: { signatures: true } },
milestones: true,
statusUpdates: {
orderBy: { createdAt: 'desc' }
}
}
})

if (!petition || petition.creatorId !== session.user.id) {
redirect('/petitions')
}

return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Manage Petition</h1>
<PetitionAdminControls petition={petition} />
</div>
)
}
156 changes: 156 additions & 0 deletions app/petitions/[id]/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client'

import { getServerSession } from "next-auth/next"
import { prisma } from "@/lib/prisma"
import { redirect } from "next/navigation"
import { Card } from "@/components/ui/card"
import { ErrorBoundary } from "react-error-boundary"
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell
} from 'recharts'

function ErrorFallback({ error, resetErrorBoundary }: {
error: Error
resetErrorBoundary: () => void
}) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h2 className="text-red-800 font-medium">Something went wrong:</h2>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-4 px-4 py-2 bg-red-100 text-red-800 rounded hover:bg-red-200"
>
Try again
</button>
</div>
)
}

function Chart({ children }: { children: React.ReactNode }) {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// Reset any state that might have caused the error
}}
>
<div className="h-[400px] relative">
{children}
</div>
</ErrorBoundary>
)
}

export default async function PetitionAnalyticsPage({ params }: { params: { id: string } }) {
const session = await getServerSession()
if (!session?.user) {
redirect('/api/auth/signin')
}

const petition = await prisma.petition.findUnique({
where: { id: params.id },
include: {
signatures: {
orderBy: { signedAt: 'asc' },
select: {
signedAt: true,
referralSource: true,
referrer: {
select: { name: true }
}
}
},
_count: {
select: { signatures: true }
}
}
})

if (!petition || petition.creatorId !== session.user.id) {
redirect('/petitions')
}

// Calculate signatures by day
const signaturesByDay = petition.signatures.reduce((acc: Record<string, number>, sig) => {
const day = sig.signedAt.toISOString().split('T')[0]
acc[day] = (acc[day] || 0) + 1
return acc
}, {})

const dailyData = Object.entries(signaturesByDay).map(([date, count]) => ({
date,
signatures: count
}))

// Calculate referral sources
const referralSources = petition.signatures.reduce((acc: Record<string, number>, sig) => {
const source = sig.referralSource?.toLowerCase() || 'direct'
acc[source] = (acc[source] || 0) + 1
return acc
}, {})

const referralData = Object.entries(referralSources).map(([source, count]) => ({
name: source,
value: count
}))

const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042']

return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Petition Analytics</h1>

<div className="grid gap-8">
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Signatures Over Time</h2>
<Chart>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={dailyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="signatures" stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</Chart>
</Card>

<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Referral Sources</h2>
<Chart>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={referralData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} (${(percent * 100).toFixed(0)}%)`}
outerRadius={150}
fill="#8884d8"
dataKey="value"
>
{referralData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Chart>
</Card>
</div>
</div>
)
}
153 changes: 153 additions & 0 deletions app/petitions/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { SignPetitionButton } from "../components/SignPetitionButton"
import { ShareButtons } from "../components/ShareButtons"
import { Comments } from "../components/Comments"
import { FollowButton } from "../components/FollowButton"
import { notFound } from "next/navigation"
import { RepresentativeMessaging } from "../components/RepresentativeMessaging"
import { Suspense } from 'react'
import { LoadingSpinner } from '@/components/ui/loading-spinner'
import { Metadata } from 'next'
import { MDXRemote } from 'next-mdx-remote/rsc'

const defaultMessageTemplate = `Dear [REP_NAME],
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.
I urge you to take action on this matter.
Sincerely,
[NAME]`

const defaultCallScript = `Hello, my name is [NAME] and I'm a constituent calling about [PETITION_TITLE].
I'm calling to urge [REP_NAME] to take action on this important issue.
This matters to me because it affects our community directly.
Thank you for your time and for passing along my message.`

export default async function PetitionPage({ params }: { params: { id: string } }) {
const session = await getServerSession(authOptions)
const petition = await prisma.petition.findUnique({
where: { id: params.id },
include: {
_count: { select: { signatures: true } },
creator: { select: { name: true } },
comments: {
orderBy: { createdAt: 'desc' },
include: {
user: {
select: { name: true, image: true }
}
}
}
}
})

if (!petition) {
notFound()
}

const [hasUserSigned, isFollowing] = await Promise.all([
session?.user?.id ? prisma.petitionSignature.findUnique({
where: {
petitionId_userId: {
petitionId: petition.id,
userId: session.user.id,
}
}
}) : null,
session?.user?.id ? prisma.petitionFollow.findUnique({
where: {
petitionId_userId: {
petitionId: petition.id,
userId: session.user.id,
}
}
}) : null
])

return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-3xl mx-auto">
{petition.imageUrl && (
<img
src={petition.imageUrl}
alt={petition.title}
className="w-full h-64 object-cover rounded-lg mb-6"
/>
)}

<h1 className="text-4xl font-bold mb-4">{petition.title}</h1>
<div className="flex items-center gap-4 text-gray-600 mb-8">
<span>Created by {petition.creator.name}</span>
<span>{petition._count.signatures} signatures</span>
</div>

<div className="prose max-w-none dark:prose-invert mb-8">
<MDXRemote source={petition.content} />
</div>

<div className="flex items-center gap-4 mb-8">
<SignPetitionButton
petitionId={petition.id}
hasSigned={!!hasUserSigned}
/>
<FollowButton
petitionId={petition.id}
initialFollowing={!!isFollowing}
/>
</div>

{hasUserSigned && (
<>
<ShareButtons
petitionId={petition.id}
userId={session!.user.id}
/>

<div className="mt-8">
<RepresentativeMessaging
petitionTitle={petition.title}
defaultMessageTemplate={petition.messageTemplate || defaultMessageTemplate}
defaultCallScript={petition.callScript || defaultCallScript}
/>
</div>
</>
)}

<Suspense fallback={<LoadingSpinner />}>
<Comments
petitionId={petition.id}
initialComments={petition.comments}
/>
</Suspense>
</div>
</div>
)
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const petition = await prisma.petition.findUnique({
where: { id: params.id },
select: {
title: true,
summary: true
}
})

if (!petition) return {}

return {
title: petition.title,
description: petition.summary,
openGraph: {
title: petition.title,
description: petition.summary,
type: 'website'
}
}
}
Loading

0 comments on commit 35188f6

Please sign in to comment.