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}
+
+
+
+
+
+
+
+ {/* Full-size Image Modal */}
+
+
+ );
+}
\ 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 (
+ <>
+
+
+ {/* Screenshot Preview Dialog */}
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx
index 14b1c87..0a94953 100644
--- a/src/components/ui/icons.tsx
+++ b/src/components/ui/icons.tsx
@@ -1,16 +1,38 @@
'use client';
import {
- Wallet,
- LogOut,
+ MessageSquare,
+ Camera,
Loader2,
+ Image,
+ Copy,
+ Download,
+ Maximize2,
+ X,
+ Users,
+ Link,
+ FileText,
+ BarChart,
+ Activity,
+ BarChart2,
+ MessageCircle,
type LucideIcon,
-} from 'lucide-react';
+} from "lucide-react";
-export type IconKey = keyof typeof Icons;
-
-export const Icons: Record = {
- wallet: Wallet,
- logout: LogOut,
- spinner: Loader2,
-};
\ No newline at end of file
+export const Icons = {
+ messageSquare: MessageSquare,
+ camera: Camera,
+ loader: Loader2,
+ image: Image,
+ copy: Copy,
+ download: Download,
+ maximize: Maximize2,
+ x: X,
+ users: Users,
+ link: Link,
+ fileText: FileText,
+ barChart: BarChart,
+ activity: Activity,
+ barChart2: BarChart2,
+ messageCircle: MessageCircle,
+} as const;
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
index 315f95a..74448fa 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -237,8 +237,6 @@ export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get("access_token")?.value;
const refreshToken = request.cookies.get("refresh_token")?.value;
- logger.error(`1. accessToken: ${accessToken} refreshToken: ${refreshToken}`);
-
// No tokens available
if (!accessToken && !refreshToken) {
return generateUnauthorizedResponse(routeType, request);
@@ -276,8 +274,6 @@ export async function middleware(request: NextRequest) {
},
});
- logger.error(`2. refreshResponse: ${refreshResponse}`);
-
if (!refreshResponse.ok) {
return generateUnauthorizedResponse(routeType, request);
}
@@ -293,8 +289,6 @@ export async function middleware(request: NextRequest) {
return generateUnauthorizedResponse(routeType, request);
}
- logger.error(`3. newAccessToken: ${newAccessToken}`);
-
const requestHeaders = new Headers(request.headers);
const requestCookies = requestHeaders.get('Cookie') || '';
const cookieArray = requestCookies.split('; ');
diff --git a/src/services/AdminFeedbackService.ts b/src/services/AdminFeedbackService.ts
new file mode 100644
index 0000000..37e6b96
--- /dev/null
+++ b/src/services/AdminFeedbackService.ts
@@ -0,0 +1,148 @@
+import { PrismaClient, type UserFeedback, Prisma } from "@prisma/client";
+import logger from "@/logging";
+
+interface UserMetadata {
+ username: string;
+ authSource: {
+ type: string;
+ id: string;
+ username: string;
+ };
+}
+
+function isUserMetadata(value: unknown): value is UserMetadata {
+ if (!value || typeof value !== 'object') return false;
+ const metadata = value as Record;
+
+ if (typeof metadata.username !== 'string') return false;
+ if (!metadata.authSource || typeof metadata.authSource !== 'object') return false;
+
+ const authSource = metadata.authSource as Record;
+ return (
+ typeof authSource.type === 'string' &&
+ typeof authSource.id === 'string' &&
+ typeof authSource.username === 'string'
+ );
+}
+
+interface FeedbackListParams {
+ page?: number;
+ limit?: number;
+ orderBy?: "asc" | "desc";
+}
+
+interface PaginatedFeedback {
+ items: (UserFeedback & {
+ user: {
+ id: string;
+ metadata: UserMetadata;
+ };
+ })[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export class AdminFeedbackService {
+ private prisma: PrismaClient;
+
+ constructor(prisma: PrismaClient) {
+ this.prisma = prisma;
+ }
+
+ async getFeedbackList({
+ page = 1,
+ limit = 10,
+ orderBy = "desc",
+ }: FeedbackListParams): Promise {
+ try {
+ const skip = (page - 1) * limit;
+
+ const [total, items] = await Promise.all([
+ this.prisma.userFeedback.count(),
+ this.prisma.userFeedback.findMany({
+ skip,
+ take: limit,
+ orderBy: {
+ createdAt: orderBy,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ metadata: true,
+ },
+ },
+ },
+ }),
+ ]);
+
+ const totalPages = Math.ceil(total / limit);
+
+ const typedItems = items.map(item => {
+ if (!isUserMetadata(item.user.metadata)) {
+ logger.error(`Invalid user metadata format for user ${item.user.id}`);
+ throw new Error(`Invalid user metadata format for user ${item.user.id}`);
+ }
+ return {
+ ...item,
+ user: {
+ id: item.user.id,
+ metadata: item.user.metadata,
+ },
+ };
+ });
+
+ return {
+ items: typedItems,
+ total,
+ page,
+ limit,
+ totalPages,
+ };
+ } catch (error) {
+ logger.error("Error in getFeedbackList:", error);
+ throw error;
+ }
+ }
+
+ async getFeedbackById(id: string): Promise<(UserFeedback & {
+ user: {
+ id: string;
+ metadata: UserMetadata;
+ };
+ }) | null> {
+ try {
+ const feedback = await this.prisma.userFeedback.findUnique({
+ where: { id },
+ include: {
+ user: {
+ select: {
+ id: true,
+ metadata: true,
+ },
+ },
+ },
+ });
+
+ if (!feedback) return null;
+
+ if (!isUserMetadata(feedback.user.metadata)) {
+ logger.error(`Invalid user metadata format for user ${feedback.user.id}`);
+ throw new Error(`Invalid user metadata format for user ${feedback.user.id}`);
+ }
+
+ return {
+ ...feedback,
+ user: {
+ id: feedback.user.id,
+ metadata: feedback.user.metadata,
+ },
+ };
+ } catch (error) {
+ logger.error("Error in getFeedbackById:", error);
+ throw error;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/services/FeedbackService.ts b/src/services/FeedbackService.ts
new file mode 100644
index 0000000..2510f1a
--- /dev/null
+++ b/src/services/FeedbackService.ts
@@ -0,0 +1,43 @@
+import { PrismaClient, UserFeedback, Prisma } from "@prisma/client";
+import logger from "@/logging";
+
+interface FeedbackInput {
+ userId: string;
+ feedback: string;
+ image?: Buffer;
+ metadata: {
+ url: string;
+ userAgent: string;
+ timestamp: string;
+ };
+}
+
+export class FeedbackService {
+ constructor(private prisma: PrismaClient) {}
+
+ async submitFeedback(input: FeedbackInput): Promise {
+ try {
+ const feedback = await this.prisma.userFeedback.create({
+ data: {
+ userId: input.userId,
+ feedback: input.feedback,
+ image: input.image,
+ metadata: input.metadata,
+ },
+ });
+
+ logger.info(`Feedback submitted for user ${input.userId}`);
+ return feedback;
+ } catch (error) {
+ logger.error("Error submitting feedback:", error);
+ throw error;
+ }
+ }
+
+ async getFeedbackByUserId(userId: string): Promise {
+ return this.prisma.userFeedback.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ });
+ }
+}
\ No newline at end of file