Skip to content
This repository was archived by the owner on Apr 20, 2026. It is now read-only.
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
29 changes: 21 additions & 8 deletions src/app/api/certifications/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import {
Certification,
isCertLevel,
isCertStatus,
} from "@/lib/certifications/model";
import { pool } from "@/lib/db/client";
import { NextRequest, NextResponse } from "next/server";

function rowToCert(row: Record<string, unknown>) {
function rowToCert(row: Record<string, unknown>): Certification {
const documents = (() => {
try {
const parsed = JSON.parse((row.documents as string) || "[]");
Expand All @@ -13,9 +18,9 @@ function rowToCert(row: Record<string, unknown>) {
return {
id: row.id,
name: row.name,
level: row.level,
authority: row.authority,
status: row.status,
level: isCertLevel(row.level) ? row.level : "Federal",
authority: typeof row.authority === "string" ? row.authority : "",
status: isCertStatus(row.status) ? row.status : "NOT_STARTED",
dueDate: row.due_date ?? undefined,
appliedDate: row.applied_date ?? undefined,
decisionExpected: row.decision_expected ?? undefined,
Expand Down Expand Up @@ -82,11 +87,19 @@ export async function PATCH(
];

for (const [col, key] of fields) {
if (body[key] !== undefined) {
updates.push(`${col} = $${paramIndex}`);
values.push(body[key] ?? null);
paramIndex++;
if (body[key] === undefined) continue;

if (key === "level" && !isCertLevel(body[key])) {
return NextResponse.json({ error: "Invalid level" }, { status: 400 });
}

if (key === "status" && !isCertStatus(body[key])) {
return NextResponse.json({ error: "Invalid status" }, { status: 400 });
}

updates.push(`${col} = $${paramIndex}`);
values.push(body[key] ?? null);
paramIndex++;
}

if (body.documents !== undefined) {
Expand Down
52 changes: 34 additions & 18 deletions src/app/api/certifications/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
CertificationsApiResponse,
Certification,
isCertLevel,
isCertStatus,
} from "@/lib/certifications/model";
import { pool } from "@/lib/db/client";
import { NextRequest, NextResponse } from "next/server";

Expand Down Expand Up @@ -26,7 +32,7 @@ async function ensureSchema() {
schemaReady = true;
}

function rowToCert(row: Record<string, unknown>) {
function rowToCert(row: Record<string, unknown>): Certification {
const documents = (() => {
try {
const parsed = JSON.parse((row.documents as string) || "[]");
Expand All @@ -38,9 +44,9 @@ function rowToCert(row: Record<string, unknown>) {
return {
id: row.id,
name: row.name,
level: row.level,
authority: row.authority,
status: row.status,
level: isCertLevel(row.level) ? row.level : "Federal",
authority: typeof row.authority === "string" ? row.authority : "",
status: isCertStatus(row.status) ? row.status : "NOT_STARTED",
dueDate: row.due_date ?? undefined,
appliedDate: row.applied_date ?? undefined,
decisionExpected: row.decision_expected ?? undefined,
Expand All @@ -61,7 +67,16 @@ export async function GET() {
decision_expected, expires_date, description, notes, documents
FROM certifications ORDER BY level, name`
);
return NextResponse.json(result.rows.map(rowToCert));

const response: CertificationsApiResponse = {
data: result.rows.map(rowToCert),
meta: {
lastUpdated: new Date().toISOString(),
source: "database",
},
};

return NextResponse.json(response);
} catch (error) {
console.error("[Certifications API] Error:", error);
return NextResponse.json({ error: error instanceof Error ? error.message : "Failed to fetch data" }, { status: 500 });
Expand All @@ -82,18 +97,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
const now = new Date().toISOString();
const level = isCertLevel(body.level) ? body.level : "Federal";
const status = isCertStatus(body.status) ? body.status : "NOT_STARTED";
const authority = typeof body.authority === "string" ? body.authority : "";
const documents = JSON.stringify(
Array.isArray(body.documents) ? body.documents : []
);
await pool.query(
const result = await pool.query(
`INSERT INTO certifications (id, name, level, authority, status, due_date, applied_date,
decision_expected, expires_date, description, notes, documents, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13)`,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13)
RETURNING id, name, level, authority, status, due_date, applied_date,
decision_expected, expires_date, description, notes, documents`,
[
id, name,
body.level || "Federal",
body.authority || "",
body.status || "NOT_STARTED",
id,
name,
level,
authority,
status,
body.dueDate ?? null,
body.appliedDate ?? null,
body.decisionExpected ?? null,
Expand All @@ -104,13 +125,8 @@ export async function POST(request: NextRequest) {
now,
]
);
return NextResponse.json({
id, name, level: body.level || "Federal", authority: body.authority || "",
status: body.status || "NOT_STARTED", documents: body.documents || [],
description: body.description, notes: body.notes,
dueDate: body.dueDate, appliedDate: body.appliedDate,
decisionExpected: body.decisionExpected, expiresDate: body.expiresDate,
});

return NextResponse.json(rowToCert(result.rows[0]));
} catch (error) {
console.error("[Certifications API] Create error:", error);
return NextResponse.json(
Expand Down
61 changes: 52 additions & 9 deletions src/app/certifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
import { useState, useEffect, useCallback } from "react";
import {
getCertificationHealth,
parseCertificationsApiResponse,
Certification,
CertStatus,
CertLevel,
} from "@/lib/mock-certifications";
CERT_LEVELS,
} from "@/lib/certifications/model";
import CertCard from "@/components/certifications/CertCard";
import CertEditModal from "@/components/certifications/CertEditModal";

const LEVEL_OPTIONS: CertLevel[] = ["Federal", "State", "Local"];
const LEVEL_OPTIONS: CertLevel[] = [...CERT_LEVELS];
const STATUS_OPTIONS: { value: CertStatus; label: string }[] = [
{ value: "NOT_STARTED", label: "Not Started" },
{ value: "IN_PROGRESS", label: "In Progress" },
Expand All @@ -24,7 +26,9 @@ export default function CertificationsPage() {
const [certifications, setCertifications] = useState<Certification[]>([]);
const [editingCert, setEditingCert] = useState<Certification | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
Expand All @@ -36,16 +40,37 @@ export default function CertificationsPage() {
dueDate: "",
});

const fetchCertifications = useCallback(async () => {
const fetchCertifications = useCallback(async (opts?: { silent?: boolean }) => {
const silent = Boolean(opts?.silent);

try {
const res = await fetch("/api/certifications");
const data = await res.json();
setCertifications(Array.isArray(data) ? data : []);
if (silent) {
setRefreshing(true);
} else {
setLoading(true);
}

const res = await fetch("/api/certifications", { cache: "no-store" });
const payload = await res.json();

if (!res.ok) {
throw new Error(
payload && typeof payload.error === "string"
? payload.error
: "Failed to load certifications"
);
}

const parsed = parseCertificationsApiResponse(payload);
setCertifications(parsed.data);
setLastUpdated(parsed.meta.lastUpdated);
setError(null);
} catch (err) {
console.error("Failed to load certifications:", err);
setError(err instanceof Error ? err.message : "Failed to load");
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);

Expand All @@ -55,7 +80,9 @@ export default function CertificationsPage() {

// Auto-refresh every 15s
useEffect(() => {
const interval = setInterval(fetchCertifications, 15000);
const interval = setInterval(() => {
fetchCertifications({ silent: true });
}, 15000);
return () => clearInterval(interval);
}, [fetchCertifications]);

Expand Down Expand Up @@ -83,6 +110,7 @@ export default function CertificationsPage() {
setCertifications((prev) =>
prev.map((c) => (c.id === saved.id ? saved : c))
);
setLastUpdated(new Date().toISOString());
setEditingCert(null);
} else {
const data = await res.json();
Expand Down Expand Up @@ -120,6 +148,7 @@ export default function CertificationsPage() {
if (res.ok) {
const created = await res.json();
setCertifications((prev) => [...prev, { ...created, documents: created.documents || [] }]);
setLastUpdated(new Date().toISOString());
setShowCreateForm(false);
setCreateForm({ name: "", level: "Federal", authority: "", status: "NOT_STARTED", description: "", dueDate: "" });
setError(null);
Expand All @@ -137,6 +166,7 @@ export default function CertificationsPage() {
const res = await fetch(`/api/certifications/${id}`, { method: "DELETE" });
if (res.ok) {
setCertifications((prev) => prev.filter((c) => c.id !== id));
setLastUpdated(new Date().toISOString());
setDeleteConfirm(null);
if (editingCert?.id === id) setEditingCert(null);
}
Expand All @@ -158,6 +188,7 @@ export default function CertificationsPage() {
</h1>
<p className="text-xs text-gray-500 font-mono">
{certifications.length} certification{certifications.length !== 1 ? "s" : ""} &middot; auto-refreshes every 15s
{lastUpdated ? ` · last updated ${new Date(lastUpdated).toLocaleTimeString()}` : ""}
</p>
</div>

Expand Down Expand Up @@ -193,10 +224,10 @@ export default function CertificationsPage() {

<button
onClick={() => fetchCertifications()}
disabled={loading}
disabled={loading || refreshing}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-100 hover:bg-gray-800 rounded-lg transition-colors disabled:opacity-50"
>
Refresh
{refreshing ? "Refreshing..." : "Refresh"}
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
Expand Down Expand Up @@ -299,6 +330,17 @@ export default function CertificationsPage() {
<div className="text-center py-12">
<p className="text-sm text-gray-500">Loading certifications...</p>
</div>
) : error && certifications.length === 0 ? (
<div className="text-center py-16">
<div className="text-3xl mb-3 opacity-50">⚠️</div>
<p className="text-amber-300 text-sm mb-4">Could not load certifications right now.</p>
<button
onClick={() => fetchCertifications()}
className="px-4 py-2 bg-amber-600/20 text-amber-300 border border-amber-500/30 rounded-lg text-sm font-medium hover:bg-amber-600/30 transition-colors"
>
Retry
</button>
</div>
) : certifications.length === 0 ? (
<div className="text-center py-16">
<div className="text-4xl mb-3 opacity-30">&#128203;</div>
Expand Down Expand Up @@ -351,6 +393,7 @@ export default function CertificationsPage() {

{editingCert && (
<CertEditModal
key={editingCert.id}
certification={editingCert}
onSave={handleSave}
onClose={() => setEditingCert(null)}
Expand Down
2 changes: 1 addition & 1 deletion src/components/certifications/CertCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Certification, CertStatus } from "@/lib/mock-certifications";
import { Certification, CertStatus } from "@/lib/certifications/model";
import CertCountdown from "./CertCountdown";

interface CertCardProps {
Expand Down
8 changes: 2 additions & 6 deletions src/components/certifications/CertEditModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useEffect } from "react";
import { Certification, CertStatus } from "@/lib/mock-certifications";
import { useState } from "react";
import { Certification, CertStatus } from "@/lib/certifications/model";

const STATUS_OPTIONS: { value: CertStatus; label: string }[] = [
{ value: "NOT_STARTED", label: "Not Started" },
Expand All @@ -25,10 +25,6 @@ export default function CertEditModal({
}: CertEditModalProps) {
const [form, setForm] = useState<Certification>({ ...certification });

useEffect(() => {
setForm({ ...certification });
}, [certification]);

const handleStatusChange = (status: CertStatus) => {
setForm((prev) => ({ ...prev, status }));
};
Expand Down
Loading
Loading