diff --git a/src/app/api/approvals/__tests__/approvals.test.tsx b/src/app/api/approvals/__tests__/approvals.test.tsx index cf18faf2..512e0968 100644 --- a/src/app/api/approvals/__tests__/approvals.test.tsx +++ b/src/app/api/approvals/__tests__/approvals.test.tsx @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@/testing/utils/render'; import { hasPermission } from '@/lib/auth/acl'; +import { ApprovalStatus, ReviewDecision } from '@/types/approvals'; import { Permission, UserRole } from '@/types/api'; import type { User } from '@/types/api'; import { SubmitForApproval } from '@/components/approvals/SubmitForApproval'; @@ -79,7 +80,7 @@ describe('Approval API route', () => { title: 'Intro to Starknet', submittedBy: 'u-instructor', submittedAt: new Date().toISOString(), - status: 'PENDING', + status: ApprovalStatus.PENDING, }, }), }); @@ -98,7 +99,7 @@ describe('Approval API route', () => { const json = await res.json(); expect(json.success).toBe(true); - expect(json.data.status).toBe('PENDING'); + expect(json.data.status).toBe(ApprovalStatus.PENDING); expect(json.data.contentId).toBe('course-42'); }); @@ -108,7 +109,7 @@ describe('Approval API route', () => { success: true, data: { id: 'approval-1', - status: 'APPROVED', + status: ApprovalStatus.APPROVED, reviewedBy: 'u-admin', reviewedAt: new Date().toISOString(), reviewNote: 'Looks good', @@ -122,7 +123,7 @@ describe('Approval API route', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'approval-1', - status: 'APPROVED', + status: ReviewDecision.APPROVED, reviewedBy: 'u-admin', reviewNote: 'Looks good', }), @@ -130,7 +131,7 @@ describe('Approval API route', () => { const json = await res.json(); expect(json.success).toBe(true); - expect(json.data.status).toBe('APPROVED'); + expect(json.data.status).toBe(ApprovalStatus.APPROVED); expect(json.data.reviewedBy).toBe('u-admin'); }); @@ -138,7 +139,7 @@ describe('Approval API route', () => { const mockFetch = vi.fn().mockResolvedValue({ json: async () => ({ success: true, - data: { id: 'approval-2', status: 'REJECTED', reviewedBy: 'u-admin' }, + data: { id: 'approval-2', status: ApprovalStatus.REJECTED, reviewedBy: 'u-admin' }, }), }); vi.stubGlobal('fetch', mockFetch); @@ -146,17 +147,17 @@ describe('Approval API route', () => { const res = await fetch('/api/approvals', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id: 'approval-2', status: 'REJECTED', reviewedBy: 'u-admin' }), + body: JSON.stringify({ id: 'approval-2', status: ReviewDecision.REJECTED, reviewedBy: 'u-admin' }), }); const json = await res.json(); - expect(json.data.status).toBe('REJECTED'); + expect(json.data.status).toBe(ApprovalStatus.REJECTED); }); it('GET returns list of approvals', async () => { const items = [ - { id: 'a1', status: 'PENDING', title: 'Course A' }, - { id: 'a2', status: 'APPROVED', title: 'Course B' }, + { id: 'a1', status: ApprovalStatus.PENDING, title: 'Course A' }, + { id: 'a2', status: ApprovalStatus.APPROVED, title: 'Course B' }, ]; vi.stubGlobal( 'fetch', @@ -212,7 +213,7 @@ describe('SubmitForApproval component', () => { vi.fn().mockResolvedValue({ json: async () => ({ success: true, - data: { id: 'a-1', status: 'PENDING', title: 'My Course' }, + data: { id: 'a-1', status: ApprovalStatus.PENDING, title: 'My Course' }, }), }), ); @@ -237,7 +238,7 @@ describe('SubmitForApproval component', () => { }); it('calls onSubmitted callback with returned item', async () => { - const returnedItem = { id: 'a-1', status: 'PENDING', title: 'My Course' }; + const returnedItem = { id: 'a-1', status: ApprovalStatus.PENDING, title: 'My Course' }; vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ json: async () => ({ success: true, data: returnedItem }) }), @@ -270,7 +271,7 @@ describe('ApprovalQueue component', () => { title: 'Blockchain Basics', submittedBy: 'instructor-1', submittedAt: new Date().toISOString(), - status: 'PENDING', + status: ApprovalStatus.PENDING, }, ]; @@ -313,7 +314,7 @@ describe('ApprovalQueue component', () => { .mockResolvedValueOnce({ json: async () => ({ success: true, - data: { ...pendingItems[0], status: 'APPROVED' }, + data: { ...pendingItems[0], status: ApprovalStatus.APPROVED }, }), }); vi.stubGlobal('fetch', mockFetch); @@ -326,7 +327,7 @@ describe('ApprovalQueue component', () => { const patchCall = mockFetch.mock.calls.find((c) => c[1]?.method === 'PATCH'); expect(patchCall).toBeDefined(); const body = JSON.parse(patchCall![1].body); - expect(body.status).toBe('APPROVED'); + expect(body.status).toBe(ReviewDecision.APPROVED); }); }); }); diff --git a/src/app/api/approvals/route.ts b/src/app/api/approvals/route.ts index 7735d7a1..901e6158 100644 --- a/src/app/api/approvals/route.ts +++ b/src/app/api/approvals/route.ts @@ -3,7 +3,8 @@ import { z } from 'zod'; import { withRateLimit } from '@/lib/ratelimit'; import { logAuditMutation } from '@/middleware/audit'; import { validateBody, validateQuery } from '@/lib/validation'; -import type { ApprovalItem } from '@/types/api'; +import { ApprovalStatus } from '@/types/approvals'; +import type { ApprovalItem, ReviewDecision } from '@/types/api'; export const runtime = 'edge'; @@ -26,13 +27,15 @@ const SubmitSchema = z.object({ const ReviewSchema = z.object({ id: z.string().min(1), - status: z.enum(['APPROVED', 'REJECTED']), + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]), reviewedBy: z.string().min(1), reviewNote: z.string().max(500).optional(), }); const ListQuerySchema = z.object({ - status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(), + status: z + .enum([ApprovalStatus.PENDING, ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + .optional(), }); // --------------------------------------------------------------------------- @@ -73,7 +76,7 @@ export async function POST(request: Request): Promise { title: result.data.title, submittedBy: result.data.submittedBy, submittedAt: new Date().toISOString(), - status: 'PENDING', + status: ApprovalStatus.PENDING, }; approvalsStore.set(item.id, item); @@ -108,7 +111,7 @@ export async function PATCH(request: Request): Promise { ); } - if (existing.status !== 'PENDING') { + if (existing.status !== ApprovalStatus.PENDING) { return addHeaders( NextResponse.json( { success: false, message: 'Only PENDING approvals can be reviewed' }, diff --git a/src/components/admin/ApprovalQueue.tsx b/src/components/admin/ApprovalQueue.tsx index 75233d3a..0955c400 100644 --- a/src/components/admin/ApprovalQueue.tsx +++ b/src/components/admin/ApprovalQueue.tsx @@ -2,18 +2,19 @@ import React, { useCallback, useEffect, useState } from 'react'; import { CheckCircle, XCircle, Clock, RefreshCw } from 'lucide-react'; +import { ApprovalStatus, ReviewDecision } from '@/types/approvals'; import { PermissionGate } from '@/app/components/auth/PermissionGate'; import { Permission, User } from '@/types/api'; -import type { ApprovalItem, ApprovalStatus } from '@/types/api'; +import type { ApprovalItem } from '@/types/api'; interface ApprovalQueueProps { user: User | null | undefined; } const STATUS_FILTER_OPTIONS: Array<{ label: string; value: ApprovalStatus | 'ALL' }> = [ - { label: 'Pending', value: 'PENDING' }, - { label: 'Approved', value: 'APPROVED' }, - { label: 'Rejected', value: 'REJECTED' }, + { label: 'Pending', value: ApprovalStatus.PENDING }, + { label: 'Approved', value: ApprovalStatus.APPROVED }, + { label: 'Rejected', value: ApprovalStatus.REJECTED }, { label: 'All', value: 'ALL' }, ]; @@ -40,7 +41,7 @@ function StatusBadge({ status }: { status: ApprovalStatus }) { export function ApprovalQueue({ user }: ApprovalQueueProps) { const [items, setItems] = useState([]); - const [filter, setFilter] = useState('PENDING'); + const [filter, setFilter] = useState(ApprovalStatus.PENDING); const [loading, setLoading] = useState(false); const [reviewNote, setReviewNote] = useState>({}); const [submitting, setSubmitting] = useState(null); @@ -66,7 +67,7 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { fetchItems(); }, [fetchItems]); - const review = async (id: string, status: 'APPROVED' | 'REJECTED') => { + const review = async (id: string, status: ReviewDecision) => { if (!user) return; setSubmitting(id); setError(null); @@ -167,7 +168,7 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) { - {item.status === 'PENDING' && ( + {item.status === ApprovalStatus.PENDING && (