Skip to content

Commit

Permalink
Merge pull request #76 from MinaFoundation/feature/admin-proposal-status
Browse files Browse the repository at this point in the history
Feature/admin proposal status
  • Loading branch information
iluxonchik authored Dec 9, 2024
2 parents 026bd9a + c812048 commit ca78915
Show file tree
Hide file tree
Showing 15 changed files with 722 additions and 35 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ COPY tsconfig.json .
COPY tailwind.config.ts .
COPY postcss.config.mjs .
COPY prisma ./prisma/
COPY public ./public

RUN npx prisma generate

Expand Down
7 changes: 0 additions & 7 deletions next.config.ts

This file was deleted.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "pgt-web-app",
"version": "0.1.14",
"version": "0.1.15",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run build:workers && next dev",
"dev:https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https --experimental-https-key ./localhost-key.pem --experimental-https-cert ./localhost.pem",
"build": "next build && npm run build:workers",
"build": "next build && npm run internal:copy-public && npm run build:workers",
"internal:copy-public": "cp -r public .next/standalone/",
"build:workers": "npx tsx src/scripts/build-workers.ts",
"start": "npx prisma migrate deploy && next start",
"start:prod": "prisma migrate deploy && node server.js",
Expand Down
11 changes: 11 additions & 0 deletions src/app/admin/proposals/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ManageProposalsComponent } from "@/components/admin/ManageProposals";
import { Metadata } from "next";

export const metadata: Metadata = {
title: "Manage Proposals | MEF Admin",
description: "Manage proposal statuses and funding round assignments",
};

export default function ManageProposalsPage() {
return <ManageProposalsComponent />;
}
78 changes: 78 additions & 0 deletions src/app/api/admin/funding-rounds/[id]/proposals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getOrCreateUserFromRequest } from "@/lib/auth";
import { AdminService } from "@/services/AdminService";
import { ApiResponse } from "@/lib/api-response";
import { AppError } from "@/lib/errors";
import { AuthErrors } from "@/constants/errors";
import { UserMetadata } from "@/services";

const adminService = new AdminService(prisma);

export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUserFromRequest(request);
if (!user) {
throw AppError.unauthorized(AuthErrors.UNAUTHORIZED);
}

// Check if user is admin
const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId);
if (!isAdmin) {
throw AppError.forbidden(AuthErrors.FORBIDDEN);
}

// Get funding round ID from params
const fundingRoundId = (await params).id;

// Verify funding round exists
const fundingRound = await prisma.fundingRound.findUnique({
where: { id: fundingRoundId },
});

if (!fundingRound) {
throw AppError.notFound("Funding round not found");
}

// Get proposals for the funding round
const proposals = await prisma.proposal.findMany({
where: {
fundingRoundId,
},
include: {
user: {
select: {
metadata: true,
},
},
fundingRound: {
select: {
name: true,
},
},
},
orderBy: [
{ status: "asc" },
{ createdAt: "desc" },
],
});

// Transform the data for the frontend
const transformedProposals = proposals.map(proposal => ({
id: proposal.id,
proposalName: proposal.proposalName,
status: proposal.status,
budgetRequest: proposal.budgetRequest,
createdAt: proposal.createdAt,
submitter: (proposal.user?.metadata as UserMetadata)?.username || "Unknown",
fundingRound: proposal.fundingRound?.name,
}));

return ApiResponse.success(transformedProposals);
} catch (error) {
return ApiResponse.error(error);
}
}
72 changes: 72 additions & 0 deletions src/app/api/admin/proposals/[id]/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getOrCreateUserFromRequest } from "@/lib/auth";
import { AdminService } from "@/services/AdminService";
import { ApiResponse } from "@/lib/api-response";
import { AppError } from "@/lib/errors";
import { AuthErrors } from "@/constants/errors";
import { ProposalStatus } from "@prisma/client";
import { z } from "zod";
import { UserMetadata } from "@/services";

const adminService = new AdminService(prisma);

// Validation schema for status update
const updateStatusSchema = z.object({
status: z.nativeEnum(ProposalStatus),
});

export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const user = await getOrCreateUserFromRequest(request);
if (!user) {
throw AppError.unauthorized(AuthErrors.UNAUTHORIZED);
}

// Check if user is admin
const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId);
if (!isAdmin) {
throw AppError.forbidden(AuthErrors.FORBIDDEN);
}

// Validate request body
const body = await request.json();
const { status } = updateStatusSchema.parse(body);

// Update proposal status
const updatedProposal = await prisma.proposal.update({
where: { id: parseInt((await params).id) },
data: { status },
include: {
user: {
select: {
metadata: true,
},
},
fundingRound: {
select: {
name: true,
},
},
},
});

return ApiResponse.success({
id: updatedProposal.id,
proposalName: updatedProposal.proposalName,
status: updatedProposal.status,
budgetRequest: updatedProposal.budgetRequest,
createdAt: updatedProposal.createdAt,
submitter: (updatedProposal.user?.metadata as UserMetadata)?.username || "Unknown",
fundingRound: updatedProposal.fundingRound?.name,
});
} catch (error) {
if (error instanceof z.ZodError) {
throw AppError.badRequest("Invalid status value");
}
return ApiResponse.error(error);
}
}
59 changes: 59 additions & 0 deletions src/app/api/admin/proposals/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getOrCreateUserFromRequest } from "@/lib/auth";
import { AdminService } from "@/services/AdminService";
import { ApiResponse } from "@/lib/api-response";
import { AppError } from "@/lib/errors";
import { AuthErrors } from "@/constants/errors";
import { UserMetadata } from "@/services";

const adminService = new AdminService(prisma);

export async function GET(request: Request) {
try {
const user = await getOrCreateUserFromRequest(request);
if (!user) {
throw AppError.unauthorized(AuthErrors.UNAUTHORIZED);
}

// Check if user is admin
const isAdmin = await adminService.checkAdminStatus(user.id, user.linkId);
if (!isAdmin) {
throw AppError.forbidden(AuthErrors.FORBIDDEN);
}

const proposals = await prisma.proposal.findMany({
include: {
user: {
select: {
metadata: true,
},
},
fundingRound: {
select: {
name: true,
},
},
},
orderBy: [
{ status: "asc" },
{ createdAt: "desc" },
],
});

// Transform the data for the frontend
const transformedProposals = proposals.map(proposal => ({
id: proposal.id,
proposalName: proposal.proposalName,
status: proposal.status,
budgetRequest: proposal.budgetRequest,
createdAt: proposal.createdAt,
submitter: (proposal.user?.metadata as UserMetadata)?.username || "Unknown",
fundingRound: proposal.fundingRound?.name,
}));

return ApiResponse.success(transformedProposals);
} catch (error) {
return ApiResponse.error(error);
}
}
59 changes: 47 additions & 12 deletions src/app/api/auth/exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { verifyToken, generateTokenPair, setTokenCookies } from "@/lib/auth/jwt"
import { AppError } from "@/lib/errors";
import { ApiResponse } from "@/lib/api-response";
import logger from "@/logging";
import { HTTPStatus } from "@/constants/errors";
import * as jose from 'jose';

export const runtime = "nodejs";

Expand All @@ -10,23 +12,56 @@ export async function POST(request: Request) {
const { initialToken } = await request.json();

if (!initialToken) {
throw new AppError("Initial token is required", 400);
throw new AppError("Initial token is required", HTTPStatus.BAD_REQUEST);
}

// Verify the initial token
const payload = await verifyToken(initialToken);
try {
// Verify the initial token
const payload = await verifyToken(initialToken);

// Generate new token pair
const { accessToken, refreshToken } = await generateTokenPair(
payload.authSource
);
// Generate new token pair
const { accessToken, refreshToken } = await generateTokenPair(
payload.authSource
);

// Create response and set cookies
const response = ApiResponse.success({ success: true });
return setTokenCookies(response, accessToken, refreshToken);

// Create response and set cookies
const response = ApiResponse.success({ success: true });
return setTokenCookies(response, accessToken, refreshToken);
} catch (error) {
// Handle specific JWT errors
if (error instanceof jose.errors.JWTExpired) {
throw new AppError("Authentication token has expired", HTTPStatus.UNAUTHORIZED);
}
if (error instanceof jose.errors.JWTInvalid) {
throw new AppError("Invalid authentication token", HTTPStatus.UNAUTHORIZED);
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
throw new AppError("Token validation failed", HTTPStatus.UNAUTHORIZED);
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
throw new AppError("Invalid token signature", HTTPStatus.UNAUTHORIZED);
}

// For any other JWT-related errors
if (error instanceof jose.errors.JOSEError) {
throw new AppError("Authentication token error", HTTPStatus.UNAUTHORIZED);
}

// Re-throw unknown errors
throw error;
}

} catch (error) {
logger.error("Token exchange error:", error);
return ApiResponse.error(error);
logger.error("Token exchange error:", error);
// If it's already an AppError, pass it through
if (error instanceof AppError) {
return ApiResponse.error(error);
}

// For unexpected errors
return ApiResponse.error(
new AppError("Authentication failed", HTTPStatus.INTERNAL_ERROR)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getOrCreateUserFromRequest } from "@/lib/auth";
import { ProposalStatus, Prisma } from "@prisma/client";
import type { UserMetadata } from "@/services/UserService";
import logger from "@/logging";

interface FormattedProposal {
Expand Down
Loading

0 comments on commit ca78915

Please sign in to comment.