Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions src/app/api/approvals/__tests__/approvals.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,7 +80,7 @@ describe('Approval API route', () => {
title: 'Intro to Starknet',
submittedBy: 'u-instructor',
submittedAt: new Date().toISOString(),
status: 'PENDING',
status: ApprovalStatus.PENDING,
},
}),
});
Expand All @@ -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');
});

Expand All @@ -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',
Expand All @@ -122,41 +123,41 @@ 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',
}),
});
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');
});

it('PATCH rejects a PENDING item', async () => {
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);

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',
Expand Down Expand Up @@ -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' },
}),
}),
);
Expand All @@ -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 }) }),
Expand Down Expand Up @@ -270,7 +271,7 @@ describe('ApprovalQueue component', () => {
title: 'Blockchain Basics',
submittedBy: 'instructor-1',
submittedAt: new Date().toISOString(),
status: 'PENDING',
status: ApprovalStatus.PENDING,
},
];

Expand Down Expand Up @@ -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);
Expand All @@ -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);
});
});
});
13 changes: 8 additions & 5 deletions src/app/api/approvals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(),
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -73,7 +76,7 @@ export async function POST(request: Request): Promise<NextResponse> {
title: result.data.title,
submittedBy: result.data.submittedBy,
submittedAt: new Date().toISOString(),
status: 'PENDING',
status: ApprovalStatus.PENDING,
};

approvalsStore.set(item.id, item);
Expand Down Expand Up @@ -108,7 +111,7 @@ export async function PATCH(request: Request): Promise<NextResponse> {
);
}

if (existing.status !== 'PENDING') {
if (existing.status !== ApprovalStatus.PENDING) {
return addHeaders(
NextResponse.json(
{ success: false, message: 'Only PENDING approvals can be reviewed' },
Expand Down
19 changes: 10 additions & 9 deletions src/components/admin/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];

Expand All @@ -40,7 +41,7 @@ function StatusBadge({ status }: { status: ApprovalStatus }) {

export function ApprovalQueue({ user }: ApprovalQueueProps) {
const [items, setItems] = useState<ApprovalItem[]>([]);
const [filter, setFilter] = useState<ApprovalStatus | 'ALL'>('PENDING');
const [filter, setFilter] = useState<ApprovalStatus | 'ALL'>(ApprovalStatus.PENDING);
const [loading, setLoading] = useState(false);
const [reviewNote, setReviewNote] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState<string | null>(null);
Expand All @@ -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);
Expand Down Expand Up @@ -167,7 +168,7 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
<StatusBadge status={item.status} />
</div>

{item.status === 'PENDING' && (
{item.status === ApprovalStatus.PENDING && (
<div className="space-y-2">
<textarea
value={reviewNote[item.id] ?? ''}
Expand All @@ -181,15 +182,15 @@ export function ApprovalQueue({ user }: ApprovalQueueProps) {
/>
<div className="flex gap-2">
<button
onClick={() => review(item.id, 'APPROVED')}
onClick={() => review(item.id, ReviewDecision.APPROVED)}
disabled={submitting === item.id}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 hover:bg-green-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
<CheckCircle className="w-4 h-4" />
Approve It
</button>
<button
onClick={() => review(item.id, 'REJECTED')}
onClick={() => review(item.id, ReviewDecision.REJECTED)}
disabled={submitting === item.id}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
>
Expand Down
6 changes: 4 additions & 2 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ export type AnalyticsEventPayload = ZodAnalyticsEventPayload;
// Approvals
// ---------------------------------------------------------------------------

export type ApprovalStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
import type { ApprovalStatus, ReviewDecision } from './approvals';

export type { ApprovalStatus, ReviewDecision } from './approvals';

export interface ApprovalItem {
id: string;
Expand All @@ -127,6 +129,6 @@ export interface SubmitApprovalRequest {
}

export interface ReviewApprovalRequest {
status: 'APPROVED' | 'REJECTED';
status: ReviewDecision;
reviewNote?: string;
}
14 changes: 14 additions & 0 deletions src/types/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ApprovalStatus = {
PENDING: 'PENDING',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
} as const;

export type ApprovalStatus = (typeof ApprovalStatus)[keyof typeof ApprovalStatus];

export const ReviewDecision = {
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
} as const;

export type ReviewDecision = (typeof ReviewDecision)[keyof typeof ReviewDecision];
Loading