diff --git a/src/app/api/certifications/[id]/route.ts b/src/app/api/certifications/[id]/route.ts index cfc307c..cec9f40 100644 --- a/src/app/api/certifications/[id]/route.ts +++ b/src/app/api/certifications/[id]/route.ts @@ -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) { +function rowToCert(row: Record): Certification { const documents = (() => { try { const parsed = JSON.parse((row.documents as string) || "[]"); @@ -13,9 +18,9 @@ function rowToCert(row: Record) { 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, @@ -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) { diff --git a/src/app/api/certifications/route.ts b/src/app/api/certifications/route.ts index 2efd8ec..770ae65 100644 --- a/src/app/api/certifications/route.ts +++ b/src/app/api/certifications/route.ts @@ -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"; @@ -26,7 +32,7 @@ async function ensureSchema() { schemaReady = true; } -function rowToCert(row: Record) { +function rowToCert(row: Record): Certification { const documents = (() => { try { const parsed = JSON.parse((row.documents as string) || "[]"); @@ -38,9 +44,9 @@ function rowToCert(row: Record) { 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, @@ -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 }); @@ -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, @@ -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( diff --git a/src/app/certifications/page.tsx b/src/app/certifications/page.tsx index 7d1fab8..63f63aa 100644 --- a/src/app/certifications/page.tsx +++ b/src/app/certifications/page.tsx @@ -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" }, @@ -24,7 +26,9 @@ export default function CertificationsPage() { const [certifications, setCertifications] = useState([]); const [editingCert, setEditingCert] = useState(null); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); const [createForm, setCreateForm] = useState({ @@ -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); } }, []); @@ -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]); @@ -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(); @@ -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); @@ -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); } @@ -158,6 +188,7 @@ export default function CertificationsPage() {

{certifications.length} certification{certifications.length !== 1 ? "s" : ""} · auto-refreshes every 15s + {lastUpdated ? ` · last updated ${new Date(lastUpdated).toLocaleTimeString()}` : ""}

@@ -193,10 +224,10 @@ export default function CertificationsPage() { + ) : certifications.length === 0 ? (
📋
@@ -351,6 +393,7 @@ export default function CertificationsPage() { {editingCert && ( setEditingCert(null)} diff --git a/src/components/certifications/CertCard.tsx b/src/components/certifications/CertCard.tsx index f6f36e3..52dbf71 100644 --- a/src/components/certifications/CertCard.tsx +++ b/src/components/certifications/CertCard.tsx @@ -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 { diff --git a/src/components/certifications/CertEditModal.tsx b/src/components/certifications/CertEditModal.tsx index 6b945bf..b788114 100644 --- a/src/components/certifications/CertEditModal.tsx +++ b/src/components/certifications/CertEditModal.tsx @@ -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" }, @@ -25,10 +25,6 @@ export default function CertEditModal({ }: CertEditModalProps) { const [form, setForm] = useState({ ...certification }); - useEffect(() => { - setForm({ ...certification }); - }, [certification]); - const handleStatusChange = (status: CertStatus) => { setForm((prev) => ({ ...prev, status })); }; diff --git a/src/lib/certifications/model.ts b/src/lib/certifications/model.ts new file mode 100644 index 0000000..d51480b --- /dev/null +++ b/src/lib/certifications/model.ts @@ -0,0 +1,171 @@ +export const CERT_LEVELS = ["Federal", "State", "Local"] as const; +export type CertLevel = (typeof CERT_LEVELS)[number]; + +export const CERT_STATUSES = [ + "NOT_STARTED", + "IN_PROGRESS", + "SUBMITTED", + "APPROVED", + "EXPIRING", + "EXPIRED", +] as const; +export type CertStatus = (typeof CERT_STATUSES)[number]; + +export interface CertDocument { + name: string; + completed: boolean; +} + +export interface Certification { + id: string; + name: string; + level: CertLevel; + authority: string; + status: CertStatus; + dueDate?: string; + appliedDate?: string; + decisionExpected?: string; + expiresDate?: string; + documents: CertDocument[]; + description?: string; + notes?: string; +} + +export interface CertificationsApiMeta { + lastUpdated: string; + source: "database"; +} + +export interface CertificationsApiResponse { + data: Certification[]; + meta: CertificationsApiMeta; +} + +export function isCertStatus(value: unknown): value is CertStatus { + return typeof value === "string" && (CERT_STATUSES as readonly string[]).includes(value); +} + +export function isCertLevel(value: unknown): value is CertLevel { + return typeof value === "string" && (CERT_LEVELS as readonly string[]).includes(value); +} + +export function toCertDocument(input: unknown): CertDocument | null { + if (!input || typeof input !== "object") return null; + const item = input as Record; + if (typeof item.name !== "string") return null; + return { + name: item.name, + completed: Boolean(item.completed), + }; +} + +export function toCertification(input: unknown): Certification | null { + if (!input || typeof input !== "object") return null; + + const value = input as Record; + if (typeof value.id !== "string" || typeof value.name !== "string") return null; + + const documents = Array.isArray(value.documents) + ? value.documents.map(toCertDocument).filter((doc): doc is CertDocument => !!doc) + : []; + + return { + id: value.id, + name: value.name, + level: isCertLevel(value.level) ? value.level : "Federal", + authority: typeof value.authority === "string" ? value.authority : "", + status: isCertStatus(value.status) ? value.status : "NOT_STARTED", + dueDate: typeof value.dueDate === "string" ? value.dueDate : undefined, + appliedDate: typeof value.appliedDate === "string" ? value.appliedDate : undefined, + decisionExpected: + typeof value.decisionExpected === "string" ? value.decisionExpected : undefined, + expiresDate: typeof value.expiresDate === "string" ? value.expiresDate : undefined, + description: typeof value.description === "string" ? value.description : undefined, + notes: typeof value.notes === "string" ? value.notes : undefined, + documents, + }; +} + +export function parseCertificationsApiResponse(payload: unknown): CertificationsApiResponse { + if (Array.isArray(payload)) { + return { + data: payload.map(toCertification).filter((item): item is Certification => !!item), + meta: { + lastUpdated: new Date().toISOString(), + source: "database", + }, + }; + } + + if (!payload || typeof payload !== "object") { + return { + data: [], + meta: { + lastUpdated: new Date().toISOString(), + source: "database", + }, + }; + } + + const record = payload as Record; + const data = Array.isArray(record.data) + ? record.data.map(toCertification).filter((item): item is Certification => !!item) + : []; + + const metaRecord = + record.meta && typeof record.meta === "object" + ? (record.meta as Record) + : null; + + return { + data, + meta: { + lastUpdated: + metaRecord && typeof metaRecord.lastUpdated === "string" + ? metaRecord.lastUpdated + : new Date().toISOString(), + source: "database", + }, + }; +} + +export function getCertificationHealth( + certs: Certification[] +): { + total: number; + onTrack: number; + atRisk: number; + critical: number; +} { + let onTrack = 0; + let atRisk = 0; + let critical = 0; + + certs.forEach((cert) => { + if (cert.status === "APPROVED") { + onTrack++; + } else if (cert.status === "EXPIRED" || cert.status === "EXPIRING") { + critical++; + } else if (cert.dueDate) { + const daysUntilDue = Math.ceil( + (new Date(cert.dueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + if (daysUntilDue < 3) { + critical++; + } else if (daysUntilDue < 14) { + atRisk++; + } else { + onTrack++; + } + } else { + onTrack++; + } + }); + + return { + total: certs.length, + onTrack, + atRisk, + critical, + }; +} diff --git a/src/lib/mock-certifications.ts b/src/lib/mock-certifications.ts deleted file mode 100644 index d596faa..0000000 --- a/src/lib/mock-certifications.ts +++ /dev/null @@ -1,70 +0,0 @@ -export type CertStatus = - | "NOT_STARTED" - | "IN_PROGRESS" - | "SUBMITTED" - | "APPROVED" - | "EXPIRING" - | "EXPIRED"; - -export type CertLevel = "Federal" | "State" | "Local"; - -export interface CertDocument { - name: string; - completed: boolean; -} - -export interface Certification { - id: string; - name: string; - level: CertLevel; - authority: string; - status: CertStatus; - dueDate?: string; // ISO date string - appliedDate?: string; - decisionExpected?: string; - expiresDate?: string; - documents: CertDocument[]; - description?: string; - notes?: string; -} - -export function getCertificationHealth( - certs: Certification[] -): { - total: number; - onTrack: number; - atRisk: number; - critical: number; -} { - let onTrack = 0; - let atRisk = 0; - let critical = 0; - - certs.forEach((cert) => { - if (cert.status === "APPROVED") { - onTrack++; - } else if (cert.status === "EXPIRED" || cert.status === "EXPIRING") { - critical++; - } else if (cert.dueDate) { - const daysUntilDue = Math.ceil( - (new Date(cert.dueDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24) - ); - if (daysUntilDue < 3) { - critical++; - } else if (daysUntilDue < 14) { - atRisk++; - } else { - onTrack++; - } - } else { - onTrack++; - } - }); - - return { - total: certs.length, - onTrack, - atRisk, - critical, - }; -}