Skip to content

Commit

Permalink
Merge pull request #108 from MinaFoundation/feature/feedback-form
Browse files Browse the repository at this point in the history
Feature/feedback form
  • Loading branch information
iluxonchik authored Jan 14, 2025
2 parents d142709 + 5702341 commit 409e96f
Show file tree
Hide file tree
Showing 16 changed files with 1,267 additions and 27 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pgt-web-app",
"version": "0.1.32",
"version": "0.1.33",
"private": true,
"type": "module",
"scripts": {
Expand Down
21 changes: 21 additions & 0 deletions prisma/migrations/20250114140328_add_user_feedback/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ model User {
deliberationReviewerVotes ReviewerDeliberationVote[]
ocvConsiderationVote OCVConsiderationVote? @relation(fields: [oCVConsiderationVoteId], references: [id])
oCVConsiderationVoteId Int?
feedback UserFeedback[]
@@index([id])
@@index([linkId])
Expand Down Expand Up @@ -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])
}
103 changes: 103 additions & 0 deletions src/app/admin/feedback/page.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedbackResponse | null>(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 (
<div className="container max-w-6xl mx-auto p-6">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center gap-2">
<Icons.loader className="h-6 w-6 animate-spin" />
<span>Loading feedback...</span>
</div>
</div>
</div>
);
}

if (!data) {
return (
<div className="container max-w-6xl mx-auto p-6">
<Card className="p-6">
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
<Icons.messageSquare className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No Feedback Available</h3>
<p className="text-sm text-muted-foreground">
There is no feedback data available at this time.
</p>
</div>
</Card>
</div>
);
}

return (
<div className="container max-w-6xl mx-auto p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold">User Feedback</h1>
<p className="text-muted-foreground mt-2">
View and manage user feedback submissions. Click on any feedback item to see more details.
</p>
</div>

<FeedbackList
items={data.items}
total={data.total}
page={data.page}
limit={data.limit}
totalPages={data.totalPages}
onPageChange={setPage}
onOrderChange={setOrderBy}
onLimitChange={setLimit}
/>
</div>
);
}
117 changes: 109 additions & 8 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <AdminDashboardComponent />;
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 (
<div className="container max-w-7xl mx-auto p-6 space-y-8">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
<p className="text-muted-foreground">
Welcome to the Admin Dashboard. Please select a category to manage.
</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{adminOptions.map((option) => {
const Icon = Icons[option.icon];
return (
<Link key={option.href} href={option.href}>
<Card className="h-full p-6 hover:shadow-md transition-all hover:scale-[1.02] cursor-pointer">
<div className="flex flex-col h-full space-y-4">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${option.color}`}>
<Icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold tracking-tight">
{option.title}
</h2>
<p className="text-sm text-muted-foreground">
{option.description}
</p>
</div>
</div>
</div>
</Card>
</Link>
);
})}
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions src/app/api/admin/feedback/list/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions src/app/api/admin/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

49 changes: 49 additions & 0 deletions src/app/api/me/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -46,6 +47,7 @@ export default function RootLayout({
<Header />
<main>{children}</main>
<Toaster />
<FeedbackDialog />
</WalletProvider>
</AuthProvider>
</Suspense>
Expand Down
Loading

0 comments on commit 409e96f

Please sign in to comment.