diff --git a/package.json b/package.json index 30d5e3c..8fe2e8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pgt-web-app", - "version": "0.1.32", + "version": "0.1.33", "private": true, "type": "module", "scripts": { diff --git a/prisma/migrations/20250114140328_add_user_feedback/migration.sql b/prisma/migrations/20250114140328_add_user_feedback/migration.sql new file mode 100644 index 0000000..89efbcf --- /dev/null +++ b/prisma/migrations/20250114140328_add_user_feedback/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "UserFeedback" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "feedback" TEXT NOT NULL, + "image" BYTEA, + "metadata" JSONB NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "UserFeedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "UserFeedback_userId_idx" ON "UserFeedback"("userId"); + +-- CreateIndex +CREATE INDEX "UserFeedback_createdAt_idx" ON "UserFeedback"("createdAt"); + +-- AddForeignKey +ALTER TABLE "UserFeedback" ADD CONSTRAINT "UserFeedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0f4f07d..4ac8302 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,7 @@ model User { deliberationReviewerVotes ReviewerDeliberationVote[] ocvConsiderationVote OCVConsiderationVote? @relation(fields: [oCVConsiderationVoteId], references: [id]) oCVConsiderationVoteId Int? + feedback UserFeedback[] @@index([id]) @@index([linkId]) @@ -310,3 +311,20 @@ model WorkerHeartbeat { @@index([name, status]) } + +model UserFeedback { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + feedback String @db.Text + image Bytes? @db.ByteA // Optional binary data for screenshot, PostgreSQL BYTEA type + metadata Json // Flexible JSON storage for additional user data + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + + // Relation to User model + user User @relation(fields: [userId], references: [id]) + + // Indexes + @@index([userId]) + @@index([createdAt]) +} \ No newline at end of file diff --git a/src/app/admin/feedback/page.tsx b/src/app/admin/feedback/page.tsx new file mode 100644 index 0000000..e58f5f8 --- /dev/null +++ b/src/app/admin/feedback/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { FeedbackList, type FeedbackItem } from "@/components/admin/feedback/FeedbackList"; +import { useToast } from "@/hooks/use-toast"; +import { Card } from "@/components/ui/card"; +import { Icons } from "@/components/ui/icons"; + +interface FeedbackResponse { + items: FeedbackItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export default function AdminFeedbackPage() { + const [isLoading, setIsLoading] = useState(true); + const [data, setData] = useState(null); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(10); + const [orderBy, setOrderBy] = useState<"asc" | "desc">("desc"); + const { toast } = useToast(); + + const fetchFeedback = useCallback(async () => { + try { + setIsLoading(true); + const response = await fetch( + `/api/admin/feedback/list?page=${page}&limit=${limit}&orderBy=${orderBy}` + ); + + if (!response.ok) { + throw new Error("Failed to fetch feedback"); + } + + const result = await response.json(); + setData(result); + } catch (error) { + toast({ + title: "Error", + description: "Failed to load feedback. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [page, limit, orderBy, toast]); + + useEffect(() => { + fetchFeedback(); + }, [fetchFeedback]); + + if (isLoading) { + return ( +
+
+
+ + Loading feedback... +
+
+
+ ); + } + + if (!data) { + return ( +
+ +
+ +

No Feedback Available

+

+ There is no feedback data available at this time. +

+
+
+
+ ); + } + + return ( +
+
+

User Feedback

+

+ View and manage user feedback submissions. Click on any feedback item to see more details. +

+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d9877c2..4adfe28 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,11 +1,112 @@ -import { AdminDashboardComponent } from "@/components/AdminDashboard"; -import { Metadata } from "next"; +'use client'; -export const metadata: Metadata = { - title: "Admin Dashboard | MEF", - description: "Admin dashboard for managing MEF platform", -}; +import { Card } from "@/components/ui/card"; +import { Icons } from "@/components/ui/icons"; +import Link from "next/link"; -export default function AdminPage() { - return ; +interface AdminOption { + title: string; + description: string; + icon: keyof typeof Icons; + href: string; + color?: string; +} + +const adminOptions: AdminOption[] = [ + { + title: "Manage Reviewers", + description: "Manage Reviewers and Users", + icon: "users", + href: "/admin/reviewers", + color: "bg-blue-500/10 text-blue-500", + }, + { + title: "Manage Discussion Topics", + description: "Manage Discussion Topics and Committees", + icon: "messageSquare", + href: "/admin/topics", + color: "bg-purple-500/10 text-purple-500", + }, + { + title: "Manage Funding Rounds", + description: "Manage Funding Rounds and Phases", + icon: "link", + href: "/admin/funding-rounds", + color: "bg-green-500/10 text-green-500", + }, + { + title: "Manage Proposal Status", + description: "Set/Override Proposal Status", + icon: "fileText", + href: "/admin/proposals", + color: "bg-orange-500/10 text-orange-500", + }, + { + title: "Count Votes", + description: "Count Votes for a Funding Round", + icon: "barChart", + href: "/admin/votes", + color: "bg-yellow-500/10 text-yellow-500", + }, + { + title: "Worker Heartbeats", + description: "Monitor background job statuses", + icon: "activity", + href: "/admin/workers", + color: "bg-red-500/10 text-red-500", + }, + { + title: "Consideration OCV Votes", + description: "Monitor OCV consideration votes", + icon: "barChart2", + href: "/admin/ocv-votes", + color: "bg-indigo-500/10 text-indigo-500", + }, + { + title: "User Feedback", + description: "View and manage user feedback submissions", + icon: "messageCircle", + href: "/admin/feedback", + color: "bg-pink-500/10 text-pink-500", + }, +]; + +export default function AdminDashboard() { + return ( +
+
+

Admin Dashboard

+

+ Welcome to the Admin Dashboard. Please select a category to manage. +

+
+ +
+ {adminOptions.map((option) => { + const Icon = Icons[option.icon]; + return ( + + +
+
+
+ +
+
+

+ {option.title} +

+

+ {option.description} +

+
+
+
+
+ + ); + })} +
+
+ ); } \ No newline at end of file diff --git a/src/app/api/admin/feedback/list/route.ts b/src/app/api/admin/feedback/list/route.ts new file mode 100644 index 0000000..12872af --- /dev/null +++ b/src/app/api/admin/feedback/list/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/lib/api-response"; +import { getOrCreateUserFromRequest } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { AdminFeedbackService } from "@/services/AdminFeedbackService"; +import { AppError } from "@/lib/errors"; +import { AuthErrors, HTTPStatus } from "@/constants/errors"; +import { AdminService } from "@/services"; + +const adminService = new AdminService(prisma); + +export async function GET(req: NextRequest) { + try { + const user = await getOrCreateUserFromRequest(req); + if (!user) { + throw new AppError("Unauthorized", HTTPStatus.UNAUTHORIZED); + } + + // Check if user is admin + const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId); + if (!isAdmin) { + throw AppError.forbidden(AuthErrors.FORBIDDEN); + } + + const searchParams = req.nextUrl.searchParams; + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "10"); + const orderBy = (searchParams.get("orderBy") || "desc") as "asc" | "desc"; + + const feedbackService = new AdminFeedbackService(prisma); + const result = await feedbackService.getFeedbackList({ + page, + limit, + orderBy, + }); + + return ApiResponse.success(result); + } catch (error) { + return ApiResponse.error(error); + } +} \ No newline at end of file diff --git a/src/app/api/admin/feedback/route.ts b/src/app/api/admin/feedback/route.ts new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/app/api/admin/feedback/route.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/me/feedback/route.ts b/src/app/api/me/feedback/route.ts new file mode 100644 index 0000000..830fda4 --- /dev/null +++ b/src/app/api/me/feedback/route.ts @@ -0,0 +1,49 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/lib/api-response"; +import { getOrCreateUserFromRequest } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { FeedbackService } from "@/services/FeedbackService"; +import { AppError } from "@/lib/errors"; +import { HTTPStatus } from "@/constants/errors"; + +export async function POST(req: NextRequest) { + try { + const user = await getOrCreateUserFromRequest(req); + if (!user) { + throw new AppError("Unauthorized", HTTPStatus.UNAUTHORIZED); + } + + const formData = await req.formData(); + const feedback = formData.get("feedback") as string; + const imageData = formData.get("image") as File | null; + const metadataStr = formData.get("metadata") as string; + + if (!feedback) { + throw new AppError("Feedback message is required", HTTPStatus.BAD_REQUEST); + } + + let imageBuffer: Buffer | undefined; + if (imageData) { + const arrayBuffer = await imageData.arrayBuffer(); + imageBuffer = Buffer.from(arrayBuffer); + } + + const metadata = metadataStr ? JSON.parse(metadataStr) : { + url: req.nextUrl.pathname, + userAgent: req.headers.get("user-agent") || "unknown", + timestamp: new Date().toISOString(), + }; + + const feedbackService = new FeedbackService(prisma); + const result = await feedbackService.submitFeedback({ + userId: user.id, + feedback, + image: imageBuffer, + metadata, + }); + + return ApiResponse.success({ success: true, feedback: result }); + } catch (error) { + return ApiResponse.error(error); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4c98f39..c7f6655 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Toaster } from "@/components/ui/toaster" import { AuthProvider } from "@/contexts/AuthContext" import { WalletProvider } from "@/contexts/WalletContext" import { Suspense } from "react"; +import { FeedbackDialog } from "@/components/feedback/FeedbackDialog"; const geistSans = localFont({ src: "./fonts/GeistVF.woff", @@ -46,6 +47,7 @@ export default function RootLayout({
{children}
+ diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 54eeb21..64c0f01 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Users, MessageSquare, Coins, FileCheck, Vote, Activity } from 'lucide-react' +import { Users, MessageSquare, Coins, FileCheck, Vote, Activity, MessageCircle } from 'lucide-react' import Link from "next/link" export function AdminDashboardComponent() { @@ -48,6 +48,12 @@ export function AdminDashboardComponent() { description: "Monitor OCV consideration votes", icon: , href: "/admin/ocv-votes" + }, + { + title: "User Feedback", + description: "View and manage user feedback submissions", + icon: , + href: "/admin/feedback" } ] @@ -61,7 +67,7 @@ export function AdminDashboardComponent() {

-
+
{adminOptions.map((option, index) => ( diff --git a/src/components/admin/feedback/FeedbackList.tsx b/src/components/admin/feedback/FeedbackList.tsx new file mode 100644 index 0000000..51a49e2 --- /dev/null +++ b/src/components/admin/feedback/FeedbackList.tsx @@ -0,0 +1,405 @@ +'use client'; + +import { useState } from "react"; +import { format } from "date-fns"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Icons } from "@/components/ui/icons"; +import { useToast } from "@/hooks/use-toast"; +import Image from "next/image"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import logger from "@/logging"; + +export interface FeedbackItem { + id: string; + userId: string; + feedback: string; + image: Buffer | null; + metadata: { + url: string; + userAgent: string; + timestamp: string; + pathname?: string; + search?: string; + }; + createdAt: string; + user: { + id: string, + metadata: { + username: string; + authSource: { + type: string; + }; + }; + }; +} + +interface FeedbackListProps { + items: FeedbackItem[]; + total: number; + page: number; + limit: number; + totalPages: number; + onPageChange: (page: number) => void; + onOrderChange: (order: "asc" | "desc") => void; + onLimitChange: (limit: number) => void; +} + +export function FeedbackList({ + items, + total, + page, + limit, + totalPages, + onPageChange, + onOrderChange, + onLimitChange, +}: FeedbackListProps) { + const [selectedFeedback, setSelectedFeedback] = useState(null); + const [imageModalOpen, setImageModalOpen] = useState(false); + const { toast } = useToast(); + + const handleItemClick = (feedback: FeedbackItem) => { + if (imageModalOpen) return; // Don't open feedback dialog if image modal is open + setSelectedFeedback(feedback); + }; + + const handleImageView = (feedback: FeedbackItem, e?: React.MouseEvent) => { + e?.stopPropagation(); + setSelectedFeedback(feedback); + logger.info(`Selected feedback: ${JSON.stringify(feedback)}`); + setImageModalOpen(true); + }; + + const handleCloseImageModal = () => { + setImageModalOpen(false); + // Clear selected feedback only if we're not showing the feedback report + if (!selectedFeedback || imageModalOpen) { + setSelectedFeedback(null); + } + }; + + const handleDownloadImage = (image: Buffer) => { + const base64 = Buffer.from(image).toString('base64'); + const link = document.createElement('a'); + link.href = `data:image/png;base64,${base64}`; + link.download = 'feedback-screenshot.png'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + toast({ + title: "Downloaded", + description: "Screenshot downloaded successfully", + }); + }; + + const handleCopyMetadata = (metadata: FeedbackItem['metadata']) => { + navigator.clipboard.writeText(JSON.stringify(metadata, null, 2)); + toast({ + title: "Copied", + description: "Metadata copied to clipboard", + }); + }; + + const truncateText = (text: string | undefined | null, length: number) => { + if (!text) return ''; + if (text.length <= length) return text; + return text.substring(0, length) + "..."; + }; + + return ( +
+ +
+
+ + +
+
+ Total: {total} items +
+
+
+ + + + + + + Date + User + Feedback + URL + Actions + + + + {items.map((item) => ( + handleItemClick(item)} + > + + {format(new Date(item.createdAt), "PPpp")} + + +
+
{truncateText(item.user.metadata.username, 20)}
+
+ ID: {truncateText(item.user.id, 8)} +
+
+
+ {truncateText(item.feedback, 50)} + + {truncateText(item.metadata.url, 30)} + + e.stopPropagation()}> +
+ {item.image && ( + + )} + +
+
+
+ ))} +
+
+
+
+ + +
+ +
+ Page {page} of {totalPages} +
+ +
+
+ + { + if (!open) setSelectedFeedback(null); + }} + > + + + User Feedback Report + + Submitted on {selectedFeedback && format(new Date(selectedFeedback.createdAt), "PPpp")} + + + + {selectedFeedback && ( +
+ {/* URL */} +
+

URL

+
+

{selectedFeedback.metadata.url}

+
+
+ + {/* Feedback Message */} +
+

Feedback Message

+
+

{selectedFeedback.feedback}

+
+
+ + {/* Screenshot - only show if there is one */} + {selectedFeedback.image && ( +
+
+

Screenshot

+
+ + +
+
+
setImageModalOpen(true)} + > + Feedback screenshot +
+
+ )} + + {/* User Information */} +
+
+

User Information

+ + {selectedFeedback.user.metadata.authSource.type} + +
+
+
Username
+
{selectedFeedback.user.metadata.username}
+
+ ID: {selectedFeedback.user.id} +
+
+
+ + {/* Metadata */} +
+
+

Metadata

+ +
+ +
+                    {JSON.stringify(selectedFeedback.metadata, null, 2)}
+                  
+
+
+
+ )} +
+
+ + {/* Full-size Image Modal */} + + + + Screenshot + + {selectedFeedback?.image && ( +
+ Feedback screenshot +
+ )} +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/feedback/FeedbackDialog.tsx b/src/components/feedback/FeedbackDialog.tsx new file mode 100644 index 0000000..4d46c5c --- /dev/null +++ b/src/components/feedback/FeedbackDialog.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useState, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/contexts/AuthContext"; +import { Icons } from "@/components/ui/icons"; +import Image from "next/image"; + +export function FeedbackDialog() { + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTakingScreenshot, setIsTakingScreenshot] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [feedback, setFeedback] = useState(""); + const [screenshot, setScreenshot] = useState(null); + const formRef = useRef(null); + const { toast } = useToast(); + const { user } = useAuth(); + + if (!user) return null; + + const handleScreenshot = async () => { + try { + setIsTakingScreenshot(true); + setIsOpen(false); // Hide dialog while taking screenshot + + // Request screen capture with specific display surface + const stream = await navigator.mediaDevices.getDisplayMedia({ + preferCurrentTab: true, + video: { + displaySurface: "browser", + selfBrowserSurface: "include", + }, + } as DisplayMediaStreamOptions); + + const video = document.createElement('video'); + video.srcObject = stream; + await video.play(); + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + ctx?.drawImage(video, 0, 0); + + // Stop all tracks + stream.getTracks().forEach(track => track.stop()); + + // Convert to base64 for preview + const base64Image = canvas.toDataURL('image/png'); + setScreenshot(base64Image); + + // Convert to File for form submission + canvas.toBlob((blob) => { + if (blob) { + const file = new File([blob], 'screenshot.png', { type: 'image/png' }); + const screenshotInput = formRef.current?.querySelector('input[name="screenshot"]') as HTMLInputElement; + if (screenshotInput) { + screenshotInput.files = createFileList([file]); + } + } + }, 'image/png'); + + } catch (error) { + toast({ + title: "Error", + description: "Failed to take screenshot. Please try again.", + variant: "destructive", + }); + } finally { + setIsTakingScreenshot(false); + setIsOpen(true); + } + }; + + // Helper function to create a FileList + const createFileList = (files: File[]) => { + const dataTransfer = new DataTransfer(); + files.forEach(file => dataTransfer.items.add(file)); + return dataTransfer.files; + }; + + const handleRemoveScreenshot = () => { + setScreenshot(null); + const screenshotInput = formRef.current?.querySelector('input[name="screenshot"]') as HTMLInputElement; + if (screenshotInput) { + screenshotInput.value = ''; + } + }; + + const handlePreviewClick = () => { + setIsPreviewOpen(true); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!feedback.trim()) return; + + try { + setIsSubmitting(true); + const formData = new FormData(); + formData.append("feedback", feedback); + + if (screenshot) { + // Convert base64 to blob + const base64Response = await fetch(screenshot); + const blob = await base64Response.blob(); + formData.append("image", blob, "screenshot.png"); + } + + // Get the current URL from the browser + const currentUrl = window.location.href; + const metadata = { + url: currentUrl, + userAgent: window.navigator.userAgent, + timestamp: new Date().toISOString(), + pathname: window.location.pathname, + search: window.location.search, + }; + + formData.append("metadata", JSON.stringify(metadata)); + + const response = await fetch("/api/me/feedback", { + method: "POST", + body: formData, + }); + + if (!response.ok) throw new Error("Failed to submit feedback"); + + toast({ + title: "Feedback submitted", + description: "Thank you for your feedback!", + }); + + setFeedback(""); + setScreenshot(null); + setIsOpen(false); + } catch (error) { + console.error('Error submitting feedback:', error); + toast({ + title: "Error", + description: "Failed to submit feedback. Please try again.", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + Send Feedback + + Help us improve by sharing your thoughts. You can optionally include a screenshot. + + +
+