From 9018b9cb33c78480321e8d5913c9fb74b5e39cf2 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:37:38 -0400 Subject: [PATCH 01/17] feat: add captain dashboard and team pages with permissions - Implemented a new Captain dashboard accessible to users with the 'captain_dashboard' permission. - Created a Captain team page for team management, including a placeholder for future features. - Updated admin page to redirect captains to their dashboard. - Added API endpoint for fetching dashboard data, including reviewed certifications and backlog counts. - Introduced new permission 'captain_dashboard' in the permissions module. --- sw-dash/src/app/admin/captain/page.tsx | 226 ++++++++++++++++++ sw-dash/src/app/admin/captain/team/page.tsx | 49 ++++ sw-dash/src/app/admin/page.tsx | 15 +- .../app/api/admin/captain/dashboard/route.ts | 83 +++++++ sw-dash/src/lib/perms.ts | 2 + 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 sw-dash/src/app/admin/captain/page.tsx create mode 100644 sw-dash/src/app/admin/captain/team/page.tsx create mode 100644 sw-dash/src/app/api/admin/captain/dashboard/route.ts diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx new file mode 100644 index 0000000..7ec96b7 --- /dev/null +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -0,0 +1,226 @@ +'use client' + +import { useEffect, useState } from 'react' +import Link from 'next/link' +import { Crew } from '@/components/admin/crew' +import { ErrorBanner } from '@/components/admin/error-banner' +import { ProfileCard } from '@/components/admin/profile-card' +import { useUser } from '@/components/providers/user-context' + +type DashboardData = { + since: string + reviewedSince: { + total: number + oldCertsReviewed: number + byReviewer: { reviewerId: number; username: string; total: number; oldCerts: number }[] + } + backlogCount: number +} + +function formatSince(sinceIso: string) { + const since = new Date(sinceIso) + const now = new Date() + const diffMs = now.getTime() - since.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins} min ago` + if (diffHours < 24) return `${diffHours} hr ago` + return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago` +} + +const STORAGE_KEY = 'captain_dashboard_since' + +function fetchDashboard(sinceIso: string | null): Promise { + const url = sinceIso + ? `/api/admin/captain/dashboard?since=${encodeURIComponent(sinceIso)}` + : '/api/admin/captain/dashboard' + return fetch(url).then((r) => (r.ok ? r.json() : Promise.reject())) +} + +export default function CaptainPage() { + const { user } = useUser() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + + const load = (sinceIso: string | null) => { + setLoading(true) + setError(false) + fetchDashboard(sinceIso) + .then(setData) + .catch(() => setError(true)) + .finally(() => setLoading(false)) + } + + useEffect(() => { + if (typeof window === 'undefined') return + const stored = localStorage.getItem(STORAGE_KEY) + load(stored) + }, []) + + const startNewPeriod = () => { + const now = new Date().toISOString() + localStorage.setItem(STORAGE_KEY, now) + load(now) + } + + if (!user) return null + + return ( +
+ + + +
+
+ +
+ +
+

+ Captain +

+
+ + Overview + + + Triage + + + Backlog + + + Team + +
+
+ +
+
+

+ Since you started this period +

+ +
+ {loading && ( +
+ loading… +
+ )} + {error && ( +
+ Failed to load dashboard +
+ )} + {data && !loading && !error && ( +
+
+

+ {formatSince(data.since)} +

+
+
+
+ {data.reviewedSince.total} +
+
projects reviewed
+
+
+
+ {data.reviewedSince.oldCertsReviewed} +
+
+ older than 5 days reviewed +
+
+
+
+
+
+ + By reviewer + + + Team → + +
+ {data.reviewedSince.byReviewer.length === 0 ? ( +

No reviews in this period

+ ) : ( +
    + {data.reviewedSince.byReviewer.map((r) => ( +
  • + {r.username} + + {r.total} reviewed + {r.oldCerts > 0 && ( + + ({r.oldCerts} older than 5d) + + )} + +
  • + ))} +
+ )} +
+
+
+ + Backlog (older than 5 days) + + + View → + +
+
+ {data.backlogCount} +
+

+ projects still in review +

+
+
+ )} +
+
+
+ ) +} diff --git a/sw-dash/src/app/admin/captain/team/page.tsx b/sw-dash/src/app/admin/captain/team/page.tsx new file mode 100644 index 0000000..813c719 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/page.tsx @@ -0,0 +1,49 @@ +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { getUser } from '@/lib/server-auth' +import { can, PERMS } from '@/lib/perms' +import { Crew } from '@/components/admin/crew' +import { ErrorBanner } from '@/components/admin/error-banner' +import { ProfileCard } from '@/components/admin/profile-card' + +export default async function CaptainTeamPage() { + const user = await getUser() + if (!user) redirect('/') + if (!can(user.role, PERMS.captain_dashboard)) redirect('/admin') + + return ( +
+ + + +
+
+ +
+ +
+

+ Captain → Team +

+
+

+ Team view: reviewer list and load. (Coming soon.) +

+ + ← Back to Overview + +
+
+
+
+ ) +} diff --git a/sw-dash/src/app/admin/page.tsx b/sw-dash/src/app/admin/page.tsx index 939897e..5fb22c6 100644 --- a/sw-dash/src/app/admin/page.tsx +++ b/sw-dash/src/app/admin/page.tsx @@ -12,6 +12,10 @@ export default async function Admin() { const user = await getUser() if (!user) redirect('/') + if (can(user.role, PERMS.captain_dashboard) && user.role === 'captain') { + redirect('/admin/captain') + } + const [pendingCerts, pendingYsws] = await Promise.all([ prisma.shipCert.count({ where: { status: 'pending' } }), prisma.yswsReview.count({ where: { status: 'pending' } }), @@ -35,12 +39,21 @@ export default async function Admin() { {(can(user.role, PERMS.users_view) || can(user.role, PERMS.eng_full) || - can(user.role, PERMS.logs_full)) && ( + can(user.role, PERMS.logs_full) || + can(user.role, PERMS.captain_dashboard)) && (

admin stuff

+ {can(user.role, PERMS.captain_dashboard) && ( + + ⚓ Captain dashboard + + )} {can(user.role, PERMS.users_view) && ( { + const now = new Date() + const fallbackSince = new Date(now.getTime() - 24 * 60 * 60 * 1000) + let since = fallbackSince + const url = new URL(req.url) + const sinceParam = url.searchParams.get('since') + if (sinceParam) { + const parsed = new Date(sinceParam) + if (!Number.isNaN(parsed.getTime())) since = parsed + } + + const backlogCutoff = new Date(now.getTime() - BACKLOG_DAYS * 24 * 60 * 60 * 1000) + + const reviewedSince = await prisma.shipCert.findMany({ + where: { + status: { in: ['approved', 'rejected'] }, + reviewCompletedAt: { gte: since }, + reviewerId: { not: null }, + }, + select: { + id: true, + reviewerId: true, + reviewCompletedAt: true, + createdAt: true, + }, + }) + + const byReviewer: Record = {} + let oldCertsReviewedSince = 0 + const oldThresholdMs = OLD_CERT_DAYS * 24 * 60 * 60 * 1000 + + for (const c of reviewedSince) { + const rid = c.reviewerId! + if (!byReviewer[rid]) byReviewer[rid] = { total: 0, oldCerts: 0 } + byReviewer[rid].total += 1 + const ageAtReview = c.reviewCompletedAt + ? c.reviewCompletedAt.getTime() - new Date(c.createdAt).getTime() + : 0 + if (ageAtReview >= oldThresholdMs) { + byReviewer[rid].oldCerts += 1 + oldCertsReviewedSince += 1 + } + } + + const reviewerIds = Object.keys(byReviewer).map(Number) + const reviewers = + reviewerIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: reviewerIds } }, + select: { id: true, username: true }, + }) + : [] + + const backlogCount = await prisma.shipCert.count({ + where: { + status: { notIn: ['approved', 'rejected'] }, + createdAt: { lt: backlogCutoff }, + }, + }) + + return NextResponse.json({ + since: since.toISOString(), + reviewedSince: { + total: reviewedSince.length, + oldCertsReviewed: oldCertsReviewedSince, + byReviewer: reviewers.map((r) => ({ + reviewerId: r.id, + username: r.username, + total: byReviewer[r.id]?.total ?? 0, + oldCerts: byReviewer[r.id]?.oldCerts ?? 0, + })), + }, + backlogCount, + }) +}) diff --git a/sw-dash/src/lib/perms.ts b/sw-dash/src/lib/perms.ts index b41c5a1..3854211 100644 --- a/sw-dash/src/lib/perms.ts +++ b/sw-dash/src/lib/perms.ts @@ -36,6 +36,7 @@ export const PERMS = { billy_btn: 'billy_btn', joe_btn: 'joe_btn', spot_check: 'spot_check', + captain_dashboard: 'captain_dashboard', submitter_edit: 'submitter_edit', submitter_delete: 'submitter_delete', @@ -61,6 +62,7 @@ export const ROLES = { PERMS.certs_admin, //PERMS.assign_admin, PERMS.support_admin, + PERMS.captain_dashboard, ], }, shipwright: { From 99fe8cd175f7dce5f805e7a3d0a0c7bdb7ffb4e9 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:41:39 -0400 Subject: [PATCH 02/17] refactor: enhance captain dashboard link with WIP indicator - Wrapped the Captain dashboard link in a relative div to include a WIP (Work In Progress) component. - Improved the styling of the link for better visual consistency. --- sw-dash/src/app/admin/page.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sw-dash/src/app/admin/page.tsx b/sw-dash/src/app/admin/page.tsx index 5fb22c6..06a6e1c 100644 --- a/sw-dash/src/app/admin/page.tsx +++ b/sw-dash/src/app/admin/page.tsx @@ -47,12 +47,15 @@ export default async function Admin() {
{can(user.role, PERMS.captain_dashboard) && ( - - ⚓ Captain dashboard - +
+ + ⚓ Captain dashboard + + +
)} {can(user.role, PERMS.users_view) && ( Date: Wed, 11 Mar 2026 14:01:15 -0400 Subject: [PATCH 03/17] copilot suggested changes --- sw-dash/src/app/admin/captain/page.tsx | 15 +++++++++--- .../app/api/admin/captain/dashboard/route.ts | 24 +++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index 7ec96b7..b2e3619 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -2,13 +2,17 @@ import { useEffect, useState } from 'react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { Crew } from '@/components/admin/crew' import { ErrorBanner } from '@/components/admin/error-banner' import { ProfileCard } from '@/components/admin/profile-card' import { useUser } from '@/components/providers/user-context' +import { can, PERMS } from '@/lib/perms' type DashboardData = { since: string + backlogDays: number + oldCertDays: number reviewedSince: { total: number oldCertsReviewed: number @@ -41,6 +45,7 @@ function fetchDashboard(sinceIso: string | null): Promise { export default function CaptainPage() { const { user } = useUser() + const router = useRouter() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) @@ -67,6 +72,10 @@ export default function CaptainPage() { } if (!user) return null + if (!can(user.role, PERMS.captain_dashboard)) { + router.replace('/admin') + return null + } return (
- older than 5 days reviewed + older than {data.oldCertDays} days reviewed
@@ -189,7 +198,7 @@ export default function CaptainPage() { {r.total} reviewed {r.oldCerts > 0 && ( - ({r.oldCerts} older than 5d) + ({r.oldCerts} older than {data.oldCertDays}d) )} @@ -201,7 +210,7 @@ export default function CaptainPage() {
- Backlog (older than 5 days) + Backlog (older than {data.backlogDays} days) { const now = new Date() @@ -14,7 +15,10 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => { const sinceParam = url.searchParams.get('since') if (sinceParam) { const parsed = new Date(sinceParam) - if (!Number.isNaN(parsed.getTime())) since = parsed + if (!Number.isNaN(parsed.getTime())) { + const minSince = new Date(now.getTime() - MAX_LOOKBACK_DAYS * 24 * 60 * 60 * 1000) + since = parsed < minSince ? minSince : parsed + } } const backlogCutoff = new Date(now.getTime() - BACKLOG_DAYS * 24 * 60 * 60 * 1000) @@ -66,17 +70,23 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => { }, }) + const byReviewerArray = reviewers + .map((r) => ({ + reviewerId: r.id, + username: r.username, + total: byReviewer[r.id]?.total ?? 0, + oldCerts: byReviewer[r.id]?.oldCerts ?? 0, + })) + .sort((a, b) => (b.total !== a.total ? b.total - a.total : a.username.localeCompare(b.username))) + return NextResponse.json({ since: since.toISOString(), + backlogDays: BACKLOG_DAYS, + oldCertDays: OLD_CERT_DAYS, reviewedSince: { total: reviewedSince.length, oldCertsReviewed: oldCertsReviewedSince, - byReviewer: reviewers.map((r) => ({ - reviewerId: r.id, - username: r.username, - total: byReviewer[r.id]?.total ?? 0, - oldCerts: byReviewer[r.id]?.oldCerts ?? 0, - })), + byReviewer: byReviewerArray, }, backlogCount, }) From c885bff92e7497e6e771a6b3781c31ba8a995f93 Mon Sep 17 00:00:00 2001 From: Eric Zilvytis Date: Wed, 11 Mar 2026 20:19:39 +0200 Subject: [PATCH 04/17] redundant profile card use + crew --- sw-dash/src/app/admin/captain/page.tsx | 9 --------- sw-dash/src/app/admin/captain/team/page.tsx | 9 --------- sw-dash/src/app/admin/page.tsx | 2 +- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index b2e3619..8f615e5 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -3,9 +3,7 @@ import { useEffect, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' -import { Crew } from '@/components/admin/crew' import { ErrorBanner } from '@/components/admin/error-banner' -import { ProfileCard } from '@/components/admin/profile-card' import { useUser } from '@/components/providers/user-context' import { can, PERMS } from '@/lib/perms' @@ -83,16 +81,9 @@ export default function CaptainPage() { role="main" aria-label="Captain dashboard" > -
-
- -
-

Captain diff --git a/sw-dash/src/app/admin/captain/team/page.tsx b/sw-dash/src/app/admin/captain/team/page.tsx index 813c719..c331f1c 100644 --- a/sw-dash/src/app/admin/captain/team/page.tsx +++ b/sw-dash/src/app/admin/captain/team/page.tsx @@ -2,9 +2,7 @@ import { redirect } from 'next/navigation' import Link from 'next/link' import { getUser } from '@/lib/server-auth' import { can, PERMS } from '@/lib/perms' -import { Crew } from '@/components/admin/crew' import { ErrorBanner } from '@/components/admin/error-banner' -import { ProfileCard } from '@/components/admin/profile-card' export default async function CaptainTeamPage() { const user = await getUser() @@ -17,16 +15,9 @@ export default async function CaptainTeamPage() { role="main" aria-label="Captain team" > -
-
- -
-

Captain → Team diff --git a/sw-dash/src/app/admin/page.tsx b/sw-dash/src/app/admin/page.tsx index 06a6e1c..c47daab 100644 --- a/sw-dash/src/app/admin/page.tsx +++ b/sw-dash/src/app/admin/page.tsx @@ -12,7 +12,7 @@ export default async function Admin() { const user = await getUser() if (!user) redirect('/') - if (can(user.role, PERMS.captain_dashboard) && user.role === 'captain') { + if (user.role === 'captain') { redirect('/admin/captain') } From 27e133ac8185bd0010ca717a24c39a6afa608a3c Mon Sep 17 00:00:00 2001 From: Eric Zilvytis Date: Wed, 11 Mar 2026 20:20:59 +0200 Subject: [PATCH 05/17] pretty --- sw-dash/src/app/admin/captain/page.tsx | 4 +--- sw-dash/src/app/api/admin/captain/dashboard/route.ts | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index 8f615e5..28e0dea 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -213,9 +213,7 @@ export default function CaptainPage() {
{data.backlogCount}
-

- projects still in review -

+

projects still in review

)} diff --git a/sw-dash/src/app/api/admin/captain/dashboard/route.ts b/sw-dash/src/app/api/admin/captain/dashboard/route.ts index ec84df0..25e7f38 100644 --- a/sw-dash/src/app/api/admin/captain/dashboard/route.ts +++ b/sw-dash/src/app/api/admin/captain/dashboard/route.ts @@ -77,7 +77,9 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => { total: byReviewer[r.id]?.total ?? 0, oldCerts: byReviewer[r.id]?.oldCerts ?? 0, })) - .sort((a, b) => (b.total !== a.total ? b.total - a.total : a.username.localeCompare(b.username))) + .sort((a, b) => + b.total !== a.total ? b.total - a.total : a.username.localeCompare(b.username) + ) return NextResponse.json({ since: since.toISOString(), From 3894d93782ba59d0404555af861e52f88c69f13b Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:18:57 -0400 Subject: [PATCH 06/17] fix: update admin page redirect logic for captain role - Simplified the redirect condition for captains by removing the explicit role check, relying solely on the permission check for the captain dashboard. --- sw-dash/src/app/admin/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sw-dash/src/app/admin/page.tsx b/sw-dash/src/app/admin/page.tsx index c47daab..a623f2c 100644 --- a/sw-dash/src/app/admin/page.tsx +++ b/sw-dash/src/app/admin/page.tsx @@ -12,7 +12,7 @@ export default async function Admin() { const user = await getUser() if (!user) redirect('/') - if (user.role === 'captain') { + if (can(user.role, PERMS.captain_dashboard)) { redirect('/admin/captain') } From de55a3cfed4cd02a421045de1d7456980fcae508 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:14:04 -0400 Subject: [PATCH 07/17] feat: enhance ship certifications and captain dashboard functionality - Added support for viewing certifications returned by admin, including a new count for returned certifications. - Updated the admin page to conditionally redirect based on user permissions and returned status. - Enhanced the CertsView component to display returned certifications with appropriate labels and reasons. - Modified API endpoints to handle filtering for returned certifications. - Improved the overall logic for fetching and displaying certification data based on user roles and permissions. --- sw-dash/src/app/admin/captain/page.tsx | 20 ++++++++++ sw-dash/src/app/admin/page.tsx | 4 -- .../admin/ship_certifications/certs-view.tsx | 37 +++++++++++++------ .../app/admin/ship_certifications/page.tsx | 21 +++++++++-- .../app/api/admin/captain/dashboard/route.ts | 9 +++++ .../api/admin/ship_certifications/route.ts | 23 ++++++++++-- sw-dash/src/lib/certs.ts | 24 ++++++++++-- 7 files changed, 112 insertions(+), 26 deletions(-) diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index 28e0dea..b014ad5 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -17,6 +17,7 @@ type DashboardData = { byReviewer: { reviewerId: number; username: string; total: number; oldCerts: number }[] } backlogCount: number + returnedCount: number } function formatSince(sinceIso: string) { @@ -215,6 +216,25 @@ export default function CaptainPage() {

projects still in review

+
+
+ + Returned by admin + + + View → + +
+
+ {data.returnedCount} +
+

+ need captain triage (excluded from main queue) +

+
)}
diff --git a/sw-dash/src/app/admin/page.tsx b/sw-dash/src/app/admin/page.tsx index a623f2c..0d3c1b0 100644 --- a/sw-dash/src/app/admin/page.tsx +++ b/sw-dash/src/app/admin/page.tsx @@ -12,10 +12,6 @@ export default async function Admin() { const user = await getUser() if (!user) redirect('/') - if (can(user.role, PERMS.captain_dashboard)) { - redirect('/admin/captain') - } - const [pendingCerts, pendingYsws] = await Promise.all([ prisma.shipCert.count({ where: { status: 'pending' } }), prisma.yswsReview.count({ where: { status: 'pending' } }), diff --git a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx index d98d87a..702110c 100644 --- a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx +++ b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx @@ -16,6 +16,10 @@ interface Props { leaderboard: Reviewer[] types: TypeCount[] } + /** When true, show "RETURNED BY ADMIN" label plus reason and returned-by. When false, show only "Returned". */ + showReturnedByAdmin?: boolean + /** When true, we are showing only returned-by-admin certs (captain view). Pass returned=1 in API calls. */ + isReturnedView?: boolean } const fmtTime = (secs: number) => { @@ -133,7 +137,7 @@ function MultiSelect({ ) } -export function CertsView({ initial }: Props) { +export function CertsView({ initial, showReturnedByAdmin = false, isReturnedView = false }: Props) { const params = useSearchParams() const router = useRouter() const [ftType, setFtType] = useState(params.get('ftType') || 'all') @@ -168,6 +172,7 @@ export function CertsView({ initial }: Props) { if (selectedTypes.length > 0) p.set('type', selectedTypes.join(',')) if (ftType !== 'all') p.set('ftType', ftType) if (status !== 'all') p.set('status', status) + if (isReturnedView) p.set('returned', '1') p.set('sortBy', sortBy) p.set('lbMode', lbMode) if (search) p.set('search', search) @@ -184,10 +189,11 @@ export function CertsView({ initial }: Props) { } finally { setLoading(false) } - }, [selectedTypes, ftType, status, sortBy, lbMode, search, from, to]) + }, [selectedTypes, ftType, status, sortBy, lbMode, search, from, to, isReturnedView]) const hasUrlFilters = !!( params.get('status') || + params.get('returned') || params.get('ftType') || params.get('type') || params.get('search') || @@ -210,12 +216,13 @@ export function CertsView({ initial }: Props) { if (selectedTypes.length > 0) p.set('type', selectedTypes.join(',')) if (ftType !== 'all') p.set('ftType', ftType) if (status !== 'pending') p.set('status', status) + if (isReturnedView) p.set('returned', '1') if (sortBy !== 'oldest') p.set('sortBy', sortBy) if (search) p.set('search', search) if (from) p.set('from', from) if (to) p.set('to', to) router.replace(`?${p}`, { scroll: false }) - }, [ftType, status, sortBy, search, from, to, selectedTypes]) + }, [ftType, status, sortBy, search, from, to, selectedTypes, isReturnedView]) useEffect(() => { const iv = setInterval(() => setNow(Date.now()), 1000) @@ -521,7 +528,7 @@ export function CertsView({ initial }: Props) { )} {c.yswsReturned ? ( - RETURNED + {showReturnedByAdmin ? 'RETURNED BY ADMIN' : 'RETURNED'} ) : (
- {c.yswsReturned && ( + {c.yswsReturned && showReturnedByAdmin && (
{c.yswsReturnReason}
by {c.yswsReturnedBy}
@@ -613,15 +620,21 @@ export function CertsView({ initial }: Props) { {c.yswsReturned ? ( -
+ showReturnedByAdmin ? ( +
+ + RETURNED BY ADMIN + +
+ {c.yswsReturnReason} +
+
by {c.yswsReturnedBy}
+
+ ) : ( - RETURNED BY ADMIN + RETURNED -
- {c.yswsReturnReason} -
-
by {c.yswsReturnedBy}
-
+ ) ) : ( } + +export default async function Ships({ searchParams }: PageProps) { const user = await getUser() if (!user) redirect('/') if (!can(user.role, PERMS.certs_view)) redirect('/admin') - const data = await getCerts({ status: 'pending', lbMode: 'weekly', sortBy: 'oldest' }) + const params = await searchParams + const returnedOnly = params.returned === '1' + if (returnedOnly && !can(user.role, PERMS.captain_dashboard)) { + redirect('/admin/captain') + } + + const data = await getCerts({ + status: 'pending', + lbMode: 'weekly', + sortBy: 'oldest', + returnedOnly: returnedOnly || undefined, + }) return (
← back @@ -28,6 +41,8 @@ export default async function Ships() { leaderboard: data.leaderboard, types: data.typeCounts, }} + showReturnedByAdmin={can(user.role, PERMS.captain_dashboard)} + isReturnedView={returnedOnly} />
diff --git a/sw-dash/src/app/api/admin/captain/dashboard/route.ts b/sw-dash/src/app/api/admin/captain/dashboard/route.ts index 25e7f38..66b23a0 100644 --- a/sw-dash/src/app/api/admin/captain/dashboard/route.ts +++ b/sw-dash/src/app/api/admin/captain/dashboard/route.ts @@ -67,6 +67,14 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => { where: { status: { notIn: ['approved', 'rejected'] }, createdAt: { lt: backlogCutoff }, + yswsReturnedAt: null, + }, + }) + + const returnedCount = await prisma.shipCert.count({ + where: { + status: 'pending', + yswsReturnedAt: { not: null }, }, }) @@ -91,5 +99,6 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => { byReviewer: byReviewerArray, }, backlogCount, + returnedCount, }) }) diff --git a/sw-dash/src/app/api/admin/ship_certifications/route.ts b/sw-dash/src/app/api/admin/ship_certifications/route.ts index d48bca4..6a3651c 100644 --- a/sw-dash/src/app/api/admin/ship_certifications/route.ts +++ b/sw-dash/src/app/api/admin/ship_certifications/route.ts @@ -1,22 +1,37 @@ import { NextResponse } from 'next/server' -import { PERMS } from '@/lib/perms' +import { can, PERMS } from '@/lib/perms' import { getCerts } from '@/lib/certs' import { api } from '@/lib/api' -export const GET = api(PERMS.certs_view)(async ({ req }) => { +export const GET = api(PERMS.certs_view)(async ({ req, user }) => { try { const { searchParams } = new URL(req.url) + const returnedOnly = searchParams.get('returned') === '1' + if (returnedOnly && !can(user.role, PERMS.captain_dashboard)) { + return NextResponse.json({ error: 'Only captains can view returned-by-admin list' }, { status: 403 }) + } + const rawType = searchParams.get('type') const type = rawType ? rawType.split(',').filter(Boolean) : null const ftType = searchParams.get('ftType') - const status = searchParams.get('status') + const status = searchParams.get('status') || (returnedOnly ? 'pending' : undefined) const sortBy = searchParams.get('sortBy') || 'newest' const lbMode = searchParams.get('lbMode') || 'weekly' const from = searchParams.get('from') const to = searchParams.get('to') const search = searchParams.get('search') - const data = await getCerts({ type, ftType, status, sortBy, lbMode, from, to, search }) + const data = await getCerts({ + type, + ftType, + status: status || undefined, + sortBy, + lbMode, + from, + to, + search, + returnedOnly: returnedOnly || undefined, + }) return NextResponse.json(data) } catch { return NextResponse.json({ error: 'shit hit the fan loading certifications' }, { status: 500 }) diff --git a/sw-dash/src/lib/certs.ts b/sw-dash/src/lib/certs.ts index 0f31c5d..d442982 100644 --- a/sw-dash/src/lib/certs.ts +++ b/sw-dash/src/lib/certs.ts @@ -10,6 +10,8 @@ interface Filters { from?: string | null to?: string | null search?: string | null + /** When true, only certs with yswsReturnedAt set (returned by admin). Requires status pending. */ + returnedOnly?: boolean } type StatsRow = { @@ -86,12 +88,12 @@ async function fetchStats(lbMode: string) { ORDER BY date ASC `, - // Stats + queue in ONE SQL query + // Stats + queue in ONE SQL query (pending = queue only, excludes returned-by-admin) prisma.$queryRaw` SELECT SUM(status = 'approved') AS approved, SUM(status = 'rejected') AS rejected, - SUM(status = 'pending') AS pending, + SUM(status = 'pending' AND yswsReturnedAt IS NULL) AS pending, SUM(reviewCompletedAt IS NOT NULL AND reviewCompletedAt >= ${today}) AS decisionsToday, SUM(createdAt >= ${today}) AS newShipsToday, SUM(reviewCompletedAt IS NOT NULL AND reviewCompletedAt >= ${yesterday} AND reviewCompletedAt < ${today}) AS decisionsYesterday, @@ -264,6 +266,13 @@ async function fetchList(filters: Filters) { if (type && type.length > 0) where.projectType = { in: type } if (filters.ftType && filters.ftType !== 'all') where.ftType = filters.ftType if (status && status !== 'all') where.status = status + if (status === 'pending') { + if (filters.returnedOnly) { + where.yswsReturnedAt = { not: null } + } else { + where.yswsReturnedAt = null + } + } if (filters.from || filters.to) { where.createdAt = { ...(filters.from ? { gte: new Date(filters.from) } : {}), @@ -276,6 +285,14 @@ async function fetchList(filters: Filters) { const orderBy = { createdAt: sortBy === 'oldest' ? 'asc' : 'desc' } as const + const groupByWhere: Record = {} + if (status && status !== 'all') { + groupByWhere.status = status + if (status === 'pending') { + groupByWhere.yswsReturnedAt = filters.returnedOnly ? { not: null } : null + } + } + const [certs, typeGroups] = await Promise.all([ prisma.shipCert.findMany({ where, @@ -304,7 +321,7 @@ async function fetchList(filters: Filters) { prisma.shipCert.groupBy({ by: ['projectType'], - where: status && status !== 'all' ? { status } : {}, + where: groupByWhere, _count: true, }), ]) @@ -363,6 +380,7 @@ async function getList(filters: Filters) { from: filters.from || null, to: filters.to || null, search: filters.search || null, + returnedOnly: filters.returnedOnly || null, }) return cache(key, 15, () => fetchList(filters)) } From f1c6e01a11b0c3292c4f2233b038323b44a5074c Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:23:43 -0400 Subject: [PATCH 08/17] feat: add spot check leaderboard to admin page - Introduced a new SpotCheckLeaderboard component to display top spot checkers. - Updated the admin page to include the leaderboard section with appropriate loading states. - Enhanced the API to return top checkers data for the leaderboard. --- sw-dash/src/app/admin/spot_checks/page.tsx | 14 ++++ .../spot_checks/spot-check-leaderboard.tsx | 84 +++++++++++++++++++ .../app/api/admin/spot_checks/stats/route.ts | 55 ++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx diff --git a/sw-dash/src/app/admin/spot_checks/page.tsx b/sw-dash/src/app/admin/spot_checks/page.tsx index e348736..f2c1f77 100644 --- a/sw-dash/src/app/admin/spot_checks/page.tsx +++ b/sw-dash/src/app/admin/spot_checks/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import Link from 'next/link' import Stats from './stats' +import SpotCheckLeaderboard from './spot-check-leaderboard' import List from './list' export default function Page() { @@ -24,6 +25,19 @@ export default function Page() {
+
+

+ Spot check leaderboard +

+
+ } + > + + + +

shipwrights diff --git a/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx new file mode 100644 index 0000000..78deab2 --- /dev/null +++ b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useEffect, useState } from 'react' +import Image from 'next/image' + +type Checker = { + id: number + username: string + avatar: string | null + total: number + approved: number + rejected: number +} + +export default function SpotCheckLeaderboard() { + const [data, setData] = useState<{ topCheckers: Checker[] } | null>(null) + const [error, setError] = useState(false) + + useEffect(() => { + fetch('/api/admin/spot_checks/stats') + .then((r) => r.json()) + .then(setData) + .catch(() => setError(true)) + }, []) + + if (error) + return ( +
+ load failed +
+ ) + if (!data) + return ( +
+ ) + + const { topCheckers } = data + if (topCheckers.length === 0) + return ( +
+ No spot checks yet — leaderboard will appear here. +
+ ) + + return ( +
+

+ Top spot checkers +

+
    + {topCheckers.map((c, i) => ( +
  • + + #{i + 1} + + {c.avatar ? ( + + ) : ( +
    + )} + + {c.username} + + + {c.total} + + + ({c.approved} pass · {c.rejected} fail) + +
  • + ))} +
+
+ ) +} diff --git a/sw-dash/src/app/api/admin/spot_checks/stats/route.ts b/sw-dash/src/app/api/admin/spot_checks/stats/route.ts index 8fa9cd0..f0d7e1c 100644 --- a/sw-dash/src/app/api/admin/spot_checks/stats/route.ts +++ b/sw-dash/src/app/api/admin/spot_checks/stats/route.ts @@ -80,9 +80,64 @@ export const GET = withParams>(PERMS.spot_check)(async () wrights.sort((a, b) => b.reviewed - a.reviewed) + const spotCheckStaff = await prisma.spotCheck.groupBy({ + by: ['staffId'], + _count: { id: true }, + }) + + const staffIds = spotCheckStaff.map((s) => s.staffId) + if (staffIds.length === 0) { + return NextResponse.json({ + stats: { total, failRate, successRate, checked, unchecked }, + wrights, + topCheckers: [], + }) + } + + const byStaffDecision = await prisma.spotCheck.groupBy({ + by: ['staffId', 'decision'], + _count: { id: true }, + where: { staffId: { in: staffIds } }, + }) + + const decisionMap = new Map() + for (const row of byStaffDecision) { + if (!decisionMap.has(row.staffId)) { + decisionMap.set(row.staffId, { approved: 0, rejected: 0 }) + } + const d = decisionMap.get(row.staffId)! + if (row.decision === 'approved') d.approved = row._count.id + else if (row.decision === 'rejected') d.rejected = row._count.id + } + + const users = await prisma.user.findMany({ + where: { id: { in: staffIds } }, + select: { id: true, username: true, avatar: true }, + }) + const userMap = new Map(users.map((u) => [u.id, u])) + + const topCheckers = spotCheckStaff + .map((s) => { + const user = userMap.get(s.staffId) + const decisions = decisionMap.get(s.staffId) || { approved: 0, rejected: 0 } + return user + ? { + id: user.id, + username: user.username, + avatar: user.avatar, + total: s._count.id, + approved: decisions.approved, + rejected: decisions.rejected, + } + : null + }) + .filter((s): s is NonNullable => s !== null) + .sort((a, b) => b.total - a.total) + return NextResponse.json({ stats: { total, failRate, successRate, checked, unchecked }, wrights, + topCheckers, }) } catch (e) { return NextResponse.json({ error: 'stats load failed' }, { status: 500 }) From e8dd614d55f01377e595e41d00c795cf7a2d7523 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:31:32 -0400 Subject: [PATCH 09/17] feat: enhance captain team functionality and activity tracking - Added new API endpoints for fetching team member activity and team list. - Implemented a detailed team member page displaying activity metrics, including reviews and spot checks. - Introduced a review activity grid and charts for visualizing member performance over time. - Updated the captain dashboard to include links to team member details and improved navigation. - Enhanced the SpotCheck model with an additional index for better query performance. --- sw-dash/prisma/schema.prisma | 1 + sw-dash/src/app/admin/captain/page.tsx | 29 +- .../captain/team/[userId]/activity-grid.tsx | 234 +++++++++++++++++ .../app/admin/captain/team/[userId]/page.tsx | 151 +++++++++++ .../captain/team/[userId]/reviews-chart.tsx | 110 ++++++++ sw-dash/src/app/admin/captain/team/page.tsx | 78 +++++- .../api/admin/captain/team/[userId]/route.ts | 25 ++ .../src/app/api/admin/captain/team/route.ts | 17 ++ sw-dash/src/lib/captain.ts | 247 ++++++++++++++++++ sw-dash/src/lib/certs.ts | 2 +- 10 files changed, 869 insertions(+), 25 deletions(-) create mode 100644 sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx create mode 100644 sw-dash/src/app/admin/captain/team/[userId]/page.tsx create mode 100644 sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx create mode 100644 sw-dash/src/app/api/admin/captain/team/[userId]/route.ts create mode 100644 sw-dash/src/app/api/admin/captain/team/route.ts create mode 100644 sw-dash/src/lib/captain.ts diff --git a/sw-dash/prisma/schema.prisma b/sw-dash/prisma/schema.prisma index d0c0a64..6040cae 100644 --- a/sw-dash/prisma/schema.prisma +++ b/sw-dash/prisma/schema.prisma @@ -436,6 +436,7 @@ model SpotCheck { @@index([certId]) @@index([staffId]) @@index([reviewerId]) + @@index([reviewerId, createdAt]) @@index([status]) @@map("spot_checks") } diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index b014ad5..2ddf72e 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -181,19 +181,22 @@ export default function CaptainPage() { ) : (
    {data.reviewedSince.byReviewer.map((r) => ( -
  • - {r.username} - - {r.total} reviewed - {r.oldCerts > 0 && ( - - ({r.oldCerts} older than {data.oldCertDays}d) - - )} - +
  • + + {r.username} + + {r.total} reviewed + {r.oldCerts > 0 && ( + + ({r.oldCerts} older than {data.oldCertDays}d) + + )} + + +
  • ))}
diff --git a/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx new file mode 100644 index 0000000..ea5bc48 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' + +type DayData = { date: string; count: number } + +const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] +const WEEKS = 12 +const DAYS_PER_WEEK = 7 + +function toDateStr(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function formatWeekLabel(dateStr: string) { + const d = new Date(dateStr + 'T12:00:00') + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +function monthLabelForWeek(weekStart: string, index: number, allWeekStarts: string[]): string { + const d = new Date(weekStart + 'T12:00:00') + if (index === 0) return d.toLocaleDateString('en-US', { month: 'short' }) + const prev = new Date(allWeekStarts[index - 1] + 'T12:00:00') + if (d.getMonth() !== prev.getMonth()) return d.toLocaleDateString('en-US', { month: 'short' }) + return '' +} + +export function ReviewActivityGrid({ data }: { data: DayData[] }) { + const byDate = useMemo(() => { + const m = new Map() + for (const { date, count } of data) m.set(date, count) + return m + }, [data]) + + const { weekStarts, gridByWeek } = useMemo(() => { + if (data.length === 0) { + return { weekStarts: [] as string[], gridByWeek: [] as { weekStart: string; dayOfWeek: number; date: string; count: number }[][] } + } + const first = data[0].date + const start = new Date(first + 'T12:00:00') + const weekStarts: string[] = [] + const gridByWeek: { weekStart: string; dayOfWeek: number; date: string; count: number }[][] = [] + for (let w = 0; w < WEEKS; w++) { + const weekStart = new Date(start) + weekStart.setDate(start.getDate() + w * 7) + const weekStartStr = toDateStr(weekStart) + weekStarts.push(weekStartStr) + const week: { weekStart: string; dayOfWeek: number; date: string; count: number }[] = [] + for (let d = 0; d < DAYS_PER_WEEK; d++) { + const cellDate = new Date(weekStart) + cellDate.setDate(weekStart.getDate() + d) + const dateStr = toDateStr(cellDate) + week.push({ + weekStart: weekStartStr, + dayOfWeek: d, + date: dateStr, + count: byDate.get(dateStr) ?? 0, + }) + } + gridByWeek.push(week) + } + return { weekStarts, gridByWeek } + }, [data, byDate]) + + const [selectedWeekIndex, setSelectedWeekIndex] = useState(weekStarts.length > 0 ? weekStarts.length - 1 : 0) + const [hoverCell, setHoverCell] = useState<{ date: string; count: number; weekTotal: number } | null>(null) + + const selectedWeekStart = weekStarts[selectedWeekIndex] ?? null + const weekTotals = useMemo(() => { + const totals = new Map() + for (const week of gridByWeek) { + let t = 0 + for (const cell of week) t += cell.count + totals.set(week[0].weekStart, t) + } + return totals + }, [gridByWeek]) + + const totalReviews = useMemo(() => data.reduce((a, b) => a + b.count, 0), [data]) + + const maxCount = useMemo(() => Math.max(1, ...data.map((d) => d.count)), [data]) + const colorFor = useCallback( + (count: number) => { + if (count <= 0) return 'bg-zinc-700/60' + const level = Math.min(4, Math.ceil((count / maxCount) * 4)) + return [ + 'bg-amber-900/70', + 'bg-amber-700/80', + 'bg-amber-600', + 'bg-amber-500', + 'bg-amber-400', + ][level] as string + }, + [maxCount] + ) + + if (weekStarts.length === 0) { + return ( +
+ No activity data +
+ ) + } + + const hoverLine = + hoverCell != null + ? `${hoverCell.count} review${hoverCell.count !== 1 ? 's' : ''} on ${hoverCell.date} · ${hoverCell.weekTotal} that week` + : 'Hover a day for details' + + const selectedWeekTotal = selectedWeekStart != null ? weekTotals.get(selectedWeekStart) ?? 0 : 0 + + return ( +
+ {/* Week selector + week-of + hover line */} +
+
+ + +
+ + Week of {selectedWeekStart != null ? formatWeekLabel(selectedWeekStart) : '—'} + + + {selectedWeekTotal} review{selectedWeekTotal !== 1 ? 's' : ''} that week + + + {hoverLine} + +
+ + {/* Header row: empty corner + month labels at boundaries, clickable week cells */} +
+
+ {weekStarts.map((weekStart, week) => { + const monthLabel = monthLabelForWeek(weekStart, week, weekStarts) + return ( + + ) + })} +
+ + {/* Grid: 7 rows (days) × 12 columns (weeks) */} +
+ {DAY_LABELS.map((label, dayOfWeek) => ( +
+
+ {label} +
+ {gridByWeek.map((week, weekIdx) => { + const cell = week[dayOfWeek] + if (!cell) return
+ const weekTotal = weekTotals.get(cell.weekStart) ?? 0 + const isSelected = selectedWeekIndex === weekIdx + return ( +
+ ))} +
+ +
+ + {totalReviews} review{totalReviews !== 1 ? 's' : ''} in 12 weeks + + + Less + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + More + +
+
+ ) +} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/page.tsx b/sw-dash/src/app/admin/captain/team/[userId]/page.tsx new file mode 100644 index 0000000..abdcf51 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/page.tsx @@ -0,0 +1,151 @@ +import { redirect, notFound } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { getUser } from '@/lib/server-auth' +import { can, PERMS } from '@/lib/perms' +import { cache, genKey } from '@/lib/cache' +import { getMemberActivity } from '@/lib/captain' +import { ErrorBanner } from '@/components/admin/error-banner' +import { ReviewActivityGrid } from './activity-grid' + +const CACHE_TTL = 90 + +interface Props { + params: Promise<{ userId: string }> +} + +export default async function CaptainTeamMemberPage({ params }: Props) { + const user = await getUser() + if (!user) redirect('/') + if (!can(user.role, PERMS.captain_dashboard)) redirect('/admin') + + const { userId: userIdParam } = await params + const userId = parseInt(userIdParam, 10) + if (isNaN(userId) || userId <= 0) notFound() + + const cacheKey = genKey('captain-team-member', { userId: String(userId) }) + const data = await cache(cacheKey, CACHE_TTL, () => getMemberActivity(userId)) + + if (!data) notFound() + + const { summary, reviewsByDay, projectTypes } = data + const diff = summary.reviewsThisWeek - summary.reviewsLastWeek + const comparison = + diff > 0 + ? `↑ ${diff} from last week` + : diff < 0 + ? `↓ ${Math.abs(diff)} from last week` + : 'same as last week' + const maxProjectTypeCount = projectTypes[0]?.total ?? 1 + + return ( +
+ + +
+ {/* Same nav pattern as captain dashboard: section label + links */} +
+

+ Captain → Team +

+
+ + ← Overview + + · + + Team + +
+
+ +
+ {/* Card 1: Who + primary KPI (same pattern as dashboard "projects reviewed" card) */} +
+
+ {data.user.avatar && ( + + )} +
+

+ {data.user.username ?? `User #${data.user.id}`} +

+

+ {data.user.role} +

+
+
+

+ Reviews this week +

+
+ + {summary.reviewsThisWeek} + + {comparison} +
+
+ + {/* Card 2: GitHub-style activity grid (last 12 weeks) */} +
+
+ + Review activity + + last 12 weeks +
+ +
+ + {/* Card 3: Project types reviewed in the last 12 weeks */} +
+
+ + Project types + + last 12 weeks +
+ {projectTypes.length === 0 ? ( +

No reviews in this period

+ ) : ( +
    + {projectTypes.map(({ projectType, total }) => ( +
  • + + {projectType} + +
    +
    +
    + + {total} + +
  • + ))} +
+ )} +
+
+
+
+ ) +} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx new file mode 100644 index 0000000..8e6378a --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx @@ -0,0 +1,110 @@ +'use client' + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + Legend, +} from 'recharts' + +type WeekBucket = { weekStart: string; total: number; approved: number; rejected: number } + +function formatWeek(weekStart: string) { + const d = new Date(weekStart + 'T12:00:00') + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + const p = payload[0].payload as WeekBucket + return ( +
+

{formatWeek(label)}

+

Approved: {p.approved}

+

Rejected: {p.rejected}

+

Total: {p.total}

+
+ ) +} + +export function ReviewsByWeekChart({ data }: { data: WeekBucket[] }) { + if (!data.length) { + return ( +
+ No reviews in the last 4 weeks +
+ ) + } + const axis = { stroke: '#78716c', style: { fontSize: '10px' } } + return ( + + + + + + } cursor={{ fill: 'rgba(245,158,11,0.1)' }} /> + Approved / Rejected} + /> + + + + + ) +} + +type SpotWeekBucket = { weekStart: string; passed: number; failed: number } + +function SpotTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + const p = payload[0].payload as SpotWeekBucket + return ( +
+

{formatWeek(label)}

+

Passed: {p.passed}

+

Failed: {p.failed}

+
+ ) +} + +export function SpotChecksByWeekChart({ data }: { data: SpotWeekBucket[] }) { + if (!data.length) { + return ( +
+ No spot checks in the last 12 weeks +
+ ) + } + const axis = { stroke: '#78716c', style: { fontSize: '10px' } } + return ( + + + + + + } cursor={{ fill: 'rgba(245,158,11,0.1)' }} /> + + + + + ) +} diff --git a/sw-dash/src/app/admin/captain/team/page.tsx b/sw-dash/src/app/admin/captain/team/page.tsx index c331f1c..f2f82e2 100644 --- a/sw-dash/src/app/admin/captain/team/page.tsx +++ b/sw-dash/src/app/admin/captain/team/page.tsx @@ -1,14 +1,33 @@ import { redirect } from 'next/navigation' import Link from 'next/link' +import Image from 'next/image' import { getUser } from '@/lib/server-auth' import { can, PERMS } from '@/lib/perms' +import { cache, genKey } from '@/lib/cache' +import { getTeamList } from '@/lib/captain' import { ErrorBanner } from '@/components/admin/error-banner' +const CACHE_TTL = 90 + +function formatLastReview(iso: string | null) { + if (!iso) return '—' + const d = new Date(iso) + const now = new Date() + const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)) + if (diffDays === 0) return 'Today' + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + export default async function CaptainTeamPage() { const user = await getUser() if (!user) redirect('/') if (!can(user.role, PERMS.captain_dashboard)) redirect('/admin') + const cacheKey = genKey('captain-team-list', {}) + const members = await cache(cacheKey, CACHE_TTL, getTeamList) + return (
Captain → Team

-
-

- Team view: reviewer list and load. (Coming soon.) -

- - ← Back to Overview - -
+ + ← Back to Overview + +
+ +
+ {members.length === 0 ? ( +
+

No reviewers in the last 90 days.

+
+ ) : ( + members.map((m) => ( + + {m.avatar ? ( + + ) : ( +
+ {m.username.charAt(0).toUpperCase()} +
+ )} +
+

{m.username}

+

+ {m.totalReviews} reviews in last 90 days + {m.lastReviewAt && ( + + · last {formatLastReview(m.lastReviewAt)} + + )} +

+
+ See all activity → + + )) + )}
diff --git a/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts b/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts new file mode 100644 index 0000000..deac78a --- /dev/null +++ b/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { withParams } from '@/lib/api' +import { PERMS } from '@/lib/perms' +import { parseId, idErr } from '@/lib/utils' +import { cache, genKey } from '@/lib/cache' +import { getMemberActivity } from '@/lib/captain' + +const CACHE_TTL = 90 + +export const GET = withParams<{ userId: string }>(PERMS.captain_dashboard)(async ({ + params, +}) => { + const userId = parseId(params.userId, 'user') + if (!userId) return idErr('user') + + const cacheKey = genKey('captain-team-member', { userId: String(userId) }) + const data = await cache(cacheKey, CACHE_TTL, () => getMemberActivity(userId)) + + if (!data) return NextResponse.json({ error: 'user not found' }, { status: 404 }) + + const headers = new Headers() + headers.set('Cache-Control', 'private, max-age=60') + + return NextResponse.json(data, { headers }) +}) diff --git a/sw-dash/src/app/api/admin/captain/team/route.ts b/sw-dash/src/app/api/admin/captain/team/route.ts new file mode 100644 index 0000000..2f04d5c --- /dev/null +++ b/sw-dash/src/app/api/admin/captain/team/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' +import { api } from '@/lib/api' +import { PERMS } from '@/lib/perms' +import { cache, genKey } from '@/lib/cache' +import { getTeamList } from '@/lib/captain' + +const CACHE_TTL = 90 + +export const GET = api(PERMS.captain_dashboard)(async () => { + const cacheKey = genKey('captain-team-list', {}) + const members = await cache(cacheKey, CACHE_TTL, getTeamList) + + const headers = new Headers() + headers.set('Cache-Control', 'private, max-age=60') + + return NextResponse.json({ members }, { headers }) +}) diff --git a/sw-dash/src/lib/captain.ts b/sw-dash/src/lib/captain.ts new file mode 100644 index 0000000..9cd7600 --- /dev/null +++ b/sw-dash/src/lib/captain.ts @@ -0,0 +1,247 @@ +import { prisma } from '@/lib/db' + +type ReviewsByWeekRow = { + weekStart: Date + total: bigint + approved: bigint + rejected: bigint +} + +type SpotChecksByWeekRow = { + weekStart: Date + passed: bigint + failed: bigint +} + +type ProjectTypeRow = { + projectType: string + total: bigint +} + +export type MemberActivityData = { + user: { id: number; username: string | null; avatar: string | null; role: string } + summary: { + reviewsThisWeek: number + reviewsLastWeek: number + reviewsAllTime: number + spotChecksPassed: number + spotChecksFailed: number + spotChecksPassRate: number + } + reviewsByWeek: { weekStart: string; total: number; approved: number; rejected: number }[] + /** One entry per day for the last 12 weeks (up to 84 days), for GitHub-style activity grid */ + reviewsByDay: { date: string; count: number }[] + spotChecksByWeek: { weekStart: string; passed: number; failed: number }[] + /** Top project types reviewed in the last 12 weeks */ + projectTypes: { projectType: string; total: number }[] +} + +type ReviewsByDayRow = { + day: Date + total: bigint +} + +export async function getMemberActivity(userId: number): Promise { + const twelveWeeksAgo = new Date() + twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 12 * 7) + + const [user, reviewsByWeekRaw, reviewsByDayRaw, spotChecksByWeekRaw, reviewsAllTime, projectTypesRaw] = + await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, avatar: true, role: true }, + }), + prisma.$queryRaw` + SELECT + DATE(DATE_SUB(reviewCompletedAt, INTERVAL WEEKDAY(reviewCompletedAt) DAY)) AS weekStart, + COUNT(*) AS total, + SUM(status = 'approved') AS approved, + SUM(status = 'rejected') AS rejected + FROM ship_certs + WHERE reviewerId = ${userId} + AND status IN ('approved', 'rejected') + AND reviewCompletedAt IS NOT NULL + AND reviewCompletedAt >= ${twelveWeeksAgo} + GROUP BY weekStart + ORDER BY weekStart ASC + `, + prisma.$queryRaw` + SELECT DATE(reviewCompletedAt) AS day, COUNT(*) AS total + FROM ship_certs + WHERE reviewerId = ${userId} + AND status IN ('approved', 'rejected') + AND reviewCompletedAt IS NOT NULL + AND reviewCompletedAt >= ${twelveWeeksAgo} + GROUP BY day + ORDER BY day ASC + `, + prisma.$queryRaw` + SELECT + DATE(DATE_SUB(createdAt, INTERVAL WEEKDAY(createdAt) DAY)) AS weekStart, + SUM(decision = 'approved') AS passed, + SUM(decision = 'rejected') AS failed + FROM spot_checks + WHERE reviewerId = ${userId} + AND createdAt >= ${twelveWeeksAgo} + GROUP BY weekStart + ORDER BY weekStart ASC + `, + prisma.shipCert.count({ + where: { + reviewerId: userId, + status: { in: ['approved', 'rejected'] }, + }, + }), + prisma.$queryRaw` + SELECT projectType, COUNT(*) AS total + FROM ship_certs + WHERE reviewerId = ${userId} + AND status IN ('approved', 'rejected') + AND reviewCompletedAt >= ${twelveWeeksAgo} + AND projectType IS NOT NULL + GROUP BY projectType + ORDER BY total DESC + LIMIT 8 + `, + ]) + + if (!user) return null + + const reviewsByWeek = reviewsByWeekRaw.map((r) => ({ + weekStart: r.weekStart.toISOString().split('T')[0], + total: Number(r.total), + approved: Number(r.approved), + rejected: Number(r.rejected), + })) + + const dayCountMap = new Map() + for (const r of reviewsByDayRaw) { + const key = r.day.toISOString().split('T')[0] + dayCountMap.set(key, Number(r.total)) + } + const toDateStr = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + const reviewsByDay: { date: string; count: number }[] = [] + // Start on Sunday so the activity grid shows real calendar weeks (Sun–Sat) + const start = new Date(twelveWeeksAgo) + start.setHours(0, 0, 0, 0) + start.setDate(start.getDate() - start.getDay()) + for (let i = 0; i < 84; i++) { + const d = new Date(start) + d.setDate(start.getDate() + i) + const key = toDateStr(d) + reviewsByDay.push({ date: key, count: dayCountMap.get(key) ?? 0 }) + } + + const spotChecksByWeek = spotChecksByWeekRaw.map((r) => ({ + weekStart: r.weekStart.toISOString().split('T')[0], + passed: Number(r.passed), + failed: Number(r.failed), + })) + + const now = new Date() + const getMonday = (d: Date) => { + const copy = new Date(d.getFullYear(), d.getMonth(), d.getDate()) + const day = copy.getDay() + const diff = day === 0 ? -6 : 1 - day + copy.setDate(copy.getDate() + diff) + return copy + } + const toLocalDateStr = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + const thisMonday = getMonday(now) + const thisMondayStr = toLocalDateStr(thisMonday) + const lastMonday = new Date(thisMonday) + lastMonday.setDate(lastMonday.getDate() - 7) + const lastMondayStr = toLocalDateStr(lastMonday) + + const thisWeekBucket = reviewsByWeek.find((b) => b.weekStart === thisMondayStr) + const lastWeekBucket = reviewsByWeek.find((b) => b.weekStart === lastMondayStr) + const reviewsThisWeek = thisWeekBucket?.total ?? 0 + const reviewsLastWeek = lastWeekBucket?.total ?? 0 + + const spotChecksAll = spotChecksByWeek.reduce( + (a, b) => ({ + passed: a.passed + b.passed, + failed: a.failed + b.failed, + }), + { passed: 0, failed: 0 } + ) + const spotTotal = spotChecksAll.passed + spotChecksAll.failed + const passRate = + spotTotal > 0 ? Number(((spotChecksAll.passed / spotTotal) * 100).toFixed(1)) : 0 + + const projectTypes = projectTypesRaw.map((r) => ({ + projectType: r.projectType, + total: Number(r.total), + })) + + return { + user: { + id: user.id, + username: user.username, + avatar: user.avatar, + role: user.role, + }, + summary: { + reviewsThisWeek, + reviewsLastWeek, + reviewsAllTime, + spotChecksPassed: spotChecksAll.passed, + spotChecksFailed: spotChecksAll.failed, + spotChecksPassRate: passRate, + }, + reviewsByWeek, + reviewsByDay, + spotChecksByWeek, + projectTypes, + } +} + +export type TeamMemberRow = { + id: number + username: string + avatar: string | null + totalReviews: number + lastReviewAt: string | null +} + +export async function getTeamList(): Promise { + const ninetyDaysAgo = new Date() + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90) + + const rows = await prisma.$queryRaw< + { reviewerId: number; totalReviews: bigint; lastReviewAt: Date | null }[] + >` + SELECT + reviewerId AS reviewerId, + COUNT(*) AS totalReviews, + MAX(reviewCompletedAt) AS lastReviewAt + FROM ship_certs + WHERE reviewerId IS NOT NULL + AND status IN ('approved', 'rejected') + AND reviewCompletedAt >= ${ninetyDaysAgo} + GROUP BY reviewerId + ORDER BY totalReviews DESC + ` + + const ids = rows.map((r) => r.reviewerId) + if (ids.length === 0) return [] + + const users = await prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, username: true, avatar: true }, + }) + const byId = new Map(users.map((u) => [u.id, u])) + + return rows.map((r) => { + const u = byId.get(r.reviewerId) + return { + id: r.reviewerId, + username: u?.username ?? `User #${r.reviewerId}`, + avatar: u?.avatar ?? null, + totalReviews: Number(r.totalReviews), + lastReviewAt: r.lastReviewAt ? r.lastReviewAt.toISOString() : null, + } + }) +} diff --git a/sw-dash/src/lib/certs.ts b/sw-dash/src/lib/certs.ts index d442982..cd1086e 100644 --- a/sw-dash/src/lib/certs.ts +++ b/sw-dash/src/lib/certs.ts @@ -380,7 +380,7 @@ async function getList(filters: Filters) { from: filters.from || null, to: filters.to || null, search: filters.search || null, - returnedOnly: filters.returnedOnly || null, + returnedOnly: filters.returnedOnly ? '1' : null, }) return cache(key, 15, () => fetchList(filters)) } From 87d10138702b0b021fe3c05608a000ef30506e6b Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:50:35 -0400 Subject: [PATCH 10/17] fix: update returned certification labels in CertsView component - Modified the display of returned certification status to include the name of the admin who returned it, defaulting to 'admin' if not specified. - Adjusted the rendering logic to show return reasons only when available, improving clarity in the CertsView component. --- .../admin/ship_certifications/certs-view.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx index 702110c..550af88 100644 --- a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx +++ b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx @@ -528,7 +528,7 @@ export function CertsView({ initial, showReturnedByAdmin = false, isReturnedView )} {c.yswsReturned ? ( - {showReturnedByAdmin ? 'RETURNED BY ADMIN' : 'RETURNED'} + {showReturnedByAdmin ? `RETURNED BY ${c.yswsReturnedBy ?? 'admin'}` : 'RETURNED'} ) : ( - {c.yswsReturned && showReturnedByAdmin && ( + {c.yswsReturned && showReturnedByAdmin && c.yswsReturnReason && (
-
{c.yswsReturnReason}
-
by {c.yswsReturnedBy}
+ {c.yswsReturnReason}
)}
@@ -623,12 +622,13 @@ export function CertsView({ initial, showReturnedByAdmin = false, isReturnedView showReturnedByAdmin ? (
- RETURNED BY ADMIN + RETURNED BY {c.yswsReturnedBy ?? 'admin'} -
- {c.yswsReturnReason} -
-
by {c.yswsReturnedBy}
+ {c.yswsReturnReason && ( +
+ {c.yswsReturnReason} +
+ )}
) : ( From 24682190ded1c7037478232580528e95b91992c5 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:52:10 -0400 Subject: [PATCH 11/17] chore: add .gitignore file to exclude IDE/editor files - Created a .gitignore file to prevent IDE-specific files, such as the .idea directory, from being tracked in the repository. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ee80de --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# IDE / Editor +.idea/ From e25ec00bb71f6a421709fa1762c38d974329314d Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:53:43 -0400 Subject: [PATCH 12/17] refactor: improve code formatting and readability across multiple components - Adjusted formatting in CaptainPage, ReviewActivityGrid, and CertsView components for better readability. - Enhanced the layout of JSX elements and improved the structure of return statements in various functions. - Ensured consistent use of line breaks and indentation in the codebase. --- sw-dash/src/app/admin/captain/page.tsx | 4 ++- .../captain/team/[userId]/activity-grid.tsx | 35 ++++++++++--------- .../captain/team/[userId]/reviews-chart.tsx | 16 +++++++-- .../admin/ship_certifications/certs-view.tsx | 8 ++--- .../spot_checks/spot-check-leaderboard.tsx | 4 +-- .../api/admin/captain/team/[userId]/route.ts | 4 +-- .../api/admin/ship_certifications/route.ts | 5 ++- sw-dash/src/lib/captain.ts | 27 ++++++++------ 8 files changed, 61 insertions(+), 42 deletions(-) diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index 2ddf72e..215c12b 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -194,7 +194,9 @@ export default function CaptainPage() { ({r.oldCerts} older than {data.oldCertDays}d) )} - + + → + diff --git a/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx index ea5bc48..a80b924 100644 --- a/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx +++ b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx @@ -34,7 +34,10 @@ export function ReviewActivityGrid({ data }: { data: DayData[] }) { const { weekStarts, gridByWeek } = useMemo(() => { if (data.length === 0) { - return { weekStarts: [] as string[], gridByWeek: [] as { weekStart: string; dayOfWeek: number; date: string; count: number }[][] } + return { + weekStarts: [] as string[], + gridByWeek: [] as { weekStart: string; dayOfWeek: number; date: string; count: number }[][], + } } const first = data[0].date const start = new Date(first + 'T12:00:00') @@ -62,8 +65,14 @@ export function ReviewActivityGrid({ data }: { data: DayData[] }) { return { weekStarts, gridByWeek } }, [data, byDate]) - const [selectedWeekIndex, setSelectedWeekIndex] = useState(weekStarts.length > 0 ? weekStarts.length - 1 : 0) - const [hoverCell, setHoverCell] = useState<{ date: string; count: number; weekTotal: number } | null>(null) + const [selectedWeekIndex, setSelectedWeekIndex] = useState( + weekStarts.length > 0 ? weekStarts.length - 1 : 0 + ) + const [hoverCell, setHoverCell] = useState<{ + date: string + count: number + weekTotal: number + } | null>(null) const selectedWeekStart = weekStarts[selectedWeekIndex] ?? null const weekTotals = useMemo(() => { @@ -83,22 +92,16 @@ export function ReviewActivityGrid({ data }: { data: DayData[] }) { (count: number) => { if (count <= 0) return 'bg-zinc-700/60' const level = Math.min(4, Math.ceil((count / maxCount) * 4)) - return [ - 'bg-amber-900/70', - 'bg-amber-700/80', - 'bg-amber-600', - 'bg-amber-500', - 'bg-amber-400', - ][level] as string + return ['bg-amber-900/70', 'bg-amber-700/80', 'bg-amber-600', 'bg-amber-500', 'bg-amber-400'][ + level + ] as string }, [maxCount] ) if (weekStarts.length === 0) { return ( -
- No activity data -
+
No activity data
) } @@ -107,7 +110,7 @@ export function ReviewActivityGrid({ data }: { data: DayData[] }) { ? `${hoverCell.count} review${hoverCell.count !== 1 ? 's' : ''} on ${hoverCell.date} · ${hoverCell.weekTotal} that week` : 'Hover a day for details' - const selectedWeekTotal = selectedWeekStart != null ? weekTotals.get(selectedWeekStart) ?? 0 : 0 + const selectedWeekTotal = selectedWeekStart != null ? (weekTotals.get(selectedWeekStart) ?? 0) : 0 return (
@@ -139,9 +142,7 @@ export function ReviewActivityGrid({ data }: { data: DayData[] }) { {selectedWeekTotal} review{selectedWeekTotal !== 1 ? 's' : ''} that week - - {hoverLine} - + {hoverLine}
{/* Header row: empty corner + month labels at boundaries, clickable week cells */} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx index 8e6378a..cd1a7a1 100644 --- a/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx +++ b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx @@ -58,8 +58,20 @@ export function ReviewsByWeekChart({ data }: { data: WeekBucket[] }) { wrapperStyle={{ fontSize: '11px' }} formatter={() => Approved / Rejected} /> - - + + ) diff --git a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx index 550af88..51d4e31 100644 --- a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx +++ b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx @@ -528,7 +528,9 @@ export function CertsView({ initial, showReturnedByAdmin = false, isReturnedView )} {c.yswsReturned ? ( - {showReturnedByAdmin ? `RETURNED BY ${c.yswsReturnedBy ?? 'admin'}` : 'RETURNED'} + {showReturnedByAdmin + ? `RETURNED BY ${c.yswsReturnedBy ?? 'admin'}` + : 'RETURNED'} ) : (
{c.yswsReturned && showReturnedByAdmin && c.yswsReturnReason && ( -
- {c.yswsReturnReason} -
+
{c.yswsReturnReason}
)}
diff --git a/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx index 78deab2..a4f92da 100644 --- a/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx +++ b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx @@ -53,9 +53,7 @@ export default function SpotCheckLeaderboard() { key={c.id} className="flex items-center gap-3 py-2 px-3 rounded-xl bg-zinc-900/50 border border-amber-900/20 hover:border-amber-800/40 transition-colors" > - - #{i + 1} - + #{i + 1} {c.avatar ? ( (PERMS.captain_dashboard)(async ({ - params, -}) => { +export const GET = withParams<{ userId: string }>(PERMS.captain_dashboard)(async ({ params }) => { const userId = parseId(params.userId, 'user') if (!userId) return idErr('user') diff --git a/sw-dash/src/app/api/admin/ship_certifications/route.ts b/sw-dash/src/app/api/admin/ship_certifications/route.ts index 6a3651c..da47e78 100644 --- a/sw-dash/src/app/api/admin/ship_certifications/route.ts +++ b/sw-dash/src/app/api/admin/ship_certifications/route.ts @@ -8,7 +8,10 @@ export const GET = api(PERMS.certs_view)(async ({ req, user }) => { const { searchParams } = new URL(req.url) const returnedOnly = searchParams.get('returned') === '1' if (returnedOnly && !can(user.role, PERMS.captain_dashboard)) { - return NextResponse.json({ error: 'Only captains can view returned-by-admin list' }, { status: 403 }) + return NextResponse.json( + { error: 'Only captains can view returned-by-admin list' }, + { status: 403 } + ) } const rawType = searchParams.get('type') diff --git a/sw-dash/src/lib/captain.ts b/sw-dash/src/lib/captain.ts index 9cd7600..f3611ec 100644 --- a/sw-dash/src/lib/captain.ts +++ b/sw-dash/src/lib/captain.ts @@ -45,13 +45,19 @@ export async function getMemberActivity(userId: number): Promise` + const [ + user, + reviewsByWeekRaw, + reviewsByDayRaw, + spotChecksByWeekRaw, + reviewsAllTime, + projectTypesRaw, + ] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, avatar: true, role: true }, + }), + prisma.$queryRaw` SELECT DATE(DATE_SUB(reviewCompletedAt, INTERVAL WEEKDAY(reviewCompletedAt) DAY)) AS weekStart, COUNT(*) AS total, @@ -65,7 +71,7 @@ export async function getMemberActivity(userId: number): Promise` + prisma.$queryRaw` SELECT DATE(reviewCompletedAt) AS day, COUNT(*) AS total FROM ship_certs WHERE reviewerId = ${userId} @@ -75,7 +81,7 @@ export async function getMemberActivity(userId: number): Promise` + prisma.$queryRaw` SELECT DATE(DATE_SUB(createdAt, INTERVAL WEEKDAY(createdAt) DAY)) AS weekStart, SUM(decision = 'approved') AS passed, @@ -168,8 +174,7 @@ export async function getMemberActivity(userId: number): Promise 0 ? Number(((spotChecksAll.passed / spotTotal) * 100).toFixed(1)) : 0 + const passRate = spotTotal > 0 ? Number(((spotChecksAll.passed / spotTotal) * 100).toFixed(1)) : 0 const projectTypes = projectTypesRaw.map((r) => ({ projectType: r.projectType, From 87c2bf5e3b6986655f4d919f5bab9d20892a7753 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:21:25 -0400 Subject: [PATCH 13/17] refactor: reorganize User model and enhance PayoutReq and Session models - Reformatted the User model for improved readability and consistency, including adjustments to field order and types. - Enhanced the PayoutReq model by adding an index for adminId and ensuring proper relation definitions. - Updated the Session model to include an index for userId and improved the structure of the user relation. - Made minor adjustments to the Assignment model for clarity in field definitions. --- sw-dash/prisma/schema.prisma | 327 ++++++++++++++++++----------------- 1 file changed, 173 insertions(+), 154 deletions(-) diff --git a/sw-dash/prisma/schema.prisma b/sw-dash/prisma/schema.prisma index 6040cae..e196994 100644 --- a/sw-dash/prisma/schema.prisma +++ b/sw-dash/prisma/schema.prisma @@ -8,46 +8,45 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - slackId String @unique - ftuid String? @unique - username String - avatar String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - isActive Boolean @default(true) - role String @default("observer") - sessionExpires DateTime? - sessionToken String? - currentChallenge String? - staffNotes String? @db.Text - skills Json? - lastSeen DateTime? - auditLogs AuditLog[] - subscriptions AssignSubsc[] - assignedReviews Assignment[] @relation("AssignedTo") - createdReviews Assignment[] @relation("CreatedBy") - yubikeys Yubikey[] - shipCerts ShipCert[] - claimedShipCerts ShipCert[] @relation("ClaimedBy") - sessions Session[] - pushSubs PushSub[] - ticketNotes TicketNote[] - - cookieBalance Float @default(0) - cookiesEarned Float @default(0) - streak Int @default(0) - lastReviewDate DateTime? @db.Date - payoutReqs PayoutReq[] @relation("PayoutReqs") - payoutApprovals PayoutReq[] @relation("PayoutApprovals") - yswsReviews YswsReview[] @relation("YswsReviewer") - - spotStaff SpotCheck[] @relation("Staff") - spotReviewed SpotCheck[] @relation("Reviewed") - spotResolved SpotCheck[] @relation("Resolved") - swApiKey String? @unique @db.VarChar(64) - - submitterNotes FtSubmitterNote[] + id Int @id @default(autoincrement()) + slackId String @unique + username String + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isActive Boolean @default(true) + role String @default("observer") + sessionExpires DateTime? + sessionToken String? + currentChallenge String? + staffNotes String? @db.Text + skills String? @db.LongText + lastSeen DateTime? + cookieBalance Float @default(0) + cookiesEarned Float @default(0) + ftuid String? @unique + lastReviewDate DateTime? @db.Date + streak Int @default(0) + swApiKey String? @unique @db.VarChar(64) + subscriptions AssignSubsc[] + assignedReviews Assignment[] @relation("AssignedTo") + createdReviews Assignment[] @relation("CreatedBy") + auditLogs AuditLog[] + submitterNotes FtSubmitterNote[] + payoutApprovals PayoutReq[] @relation("PayoutApprovals") + payoutReqs PayoutReq[] @relation("PayoutReqs") + pushSubs PushSub[] + sessions Session[] + claimedShipCerts ShipCert[] @relation("ClaimedBy") + shipCerts ShipCert[] + spot_check_sessions_spot_check_sessions_staffIdTousers spot_check_sessions[] @relation("spot_check_sessions_staffIdTousers") + spot_check_sessions_spot_check_sessions_wrightIdTousers spot_check_sessions[] @relation("spot_check_sessions_wrightIdTousers") + spotResolved SpotCheck[] @relation("Resolved") + spotReviewed SpotCheck[] @relation("Reviewed") + spotStaff SpotCheck[] @relation("Staff") + ticketNotes TicketNote[] + yswsReviews YswsReview[] @relation("YswsReviewer") + yubikeys Yubikey[] @@map("users") } @@ -56,9 +55,6 @@ model PayoutReq { id Int @id @default(autoincrement()) userId Int amount Float - bonus Float @default(0) - bonusReason String? - finalAmount Float? balBefore Float balAfter Float? status String @default("pending") @@ -66,12 +62,15 @@ model PayoutReq { proofUrl String? createdAt DateTime @default(now()) approvedAt DateTime? - - user User @relation("PayoutReqs", fields: [userId], references: [id]) - admin User? @relation("PayoutApprovals", fields: [adminId], references: [id]) + bonus Float @default(0) + bonusReason String? + finalAmount Float? + admin User? @relation("PayoutApprovals", fields: [adminId], references: [id]) + user User @relation("PayoutReqs", fields: [userId], references: [id]) @@index([userId]) @@index([status]) + @@index([adminId], map: "payout_reqs_adminId_fkey") @@map("payout_reqs") } @@ -93,12 +92,13 @@ model Session { id String @id @default(cuid()) token String @unique userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) device String? - ip String? createdAt DateTime @default(now()) expiresAt DateTime + ip String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId], map: "sessions_userId_fkey") @@map("sessions") } @@ -120,17 +120,17 @@ model Assignment { repoUrl String @db.Text description String? status String @default("pending") - comments String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt assigneeId Int? - projectName String? + comments String? @db.Text demoUrl String? @db.Text + projectName String? shipCertId Int? @unique subscriptions AssignSubsc[] assignee User? @relation("AssignedTo", fields: [assigneeId], references: [id]) - author User @relation("CreatedBy", fields: [userId], references: [id]) shipCert ShipCert? @relation(fields: [shipCertId], references: [id]) + author User @relation("CreatedBy", fields: [userId], references: [id]) @@index([assigneeId]) @@index([userId]) @@ -154,51 +154,47 @@ model AssignSubsc { } model ShipCert { - id Int @id @default(autoincrement()) - ftProjectId String? - ftSlackId String? - ftUsername String? - ftType String? - - projectName String? - projectType String? - description String? @db.Text - demoUrl String? @db.Text - repoUrl String? @db.Text - readmeUrl String? @db.Text - devTime String? - - status String @default("pending") - reviewerId Int? - claimerId Int? - internalNotes String? @db.Text - reviewFeedback String? @db.Text - proofVideoUrl String? - reviewStartedAt DateTime? - reviewCompletedAt DateTime? - syncedToFt Boolean @default(false) - cookiesEarned Float? - payoutMulti Float? - customBounty Float? - aiSummary String? @db.Text - - yswsReturnReason String? @db.Text - yswsReturnedBy String? - yswsReturnedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - reviewer User? @relation(fields: [reviewerId], references: [id]) - claimer User? @relation("ClaimedBy", fields: [claimerId], references: [id]) - assignments Assignment[] - yswsReviews YswsReview[] - spotChecks SpotCheck[] - - spotChecked Boolean @default(false) - spotCheckedAt DateTime? - spotCheckedBy Int? - spotPassed Boolean? - spotRemoved Boolean @default(false) + id Int @id @default(autoincrement()) + ftProjectId String? + ftSlackId String? + ftUsername String? + projectName String? + projectType String? + description String? @db.Text + demoUrl String? @db.Text + repoUrl String? @db.Text + readmeUrl String? @db.Text + devTime String? + status String @default("pending") + reviewerId Int? + internalNotes String? @db.Text + reviewFeedback String? @db.Text + proofVideoUrl String? + reviewStartedAt DateTime? + reviewCompletedAt DateTime? + syncedToFt Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + claimerId Int? + cookiesEarned Float? + payoutMulti Float? + yswsReturnReason String? @db.Text + yswsReturnedAt DateTime? + yswsReturnedBy String? + customBounty Float? + aiSummary String? @db.Text + ftType String? + spotChecked Boolean @default(false) + spotCheckedAt DateTime? + spotCheckedBy Int? + spotPassed Boolean? + spotRemoved Boolean @default(false) + assignments Assignment? + claimer User? @relation("ClaimedBy", fields: [claimerId], references: [id]) + reviewer User? @relation(fields: [reviewerId], references: [id]) + spot_check_session_certs spot_check_session_certs[] + spotChecks SpotCheck[] + yswsReviews YswsReview[] @@index([reviewerId]) @@index([status]) @@ -209,6 +205,7 @@ model ShipCert { @@index([status, projectType]) @@index([status, yswsReturnedAt, createdAt]) @@index([status, reviewCompletedAt, reviewerId]) + @@index([claimerId], map: "ship_certs_claimerId_fkey") @@map("ship_certs") } @@ -223,6 +220,7 @@ model FtSubmitterNote { staff User @relation(fields: [staffId], references: [id]) @@index([slackId]) + @@index([staffId], map: "ft_submitter_notes_staffId_fkey") @@map("ft_submitter_notes") } @@ -232,14 +230,13 @@ model YswsReview { status String @default("pending") reviewerId Int? returnReason String? @db.Text - devlogs Json? - commits Json? - decisions Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - shipCert ShipCert @relation(fields: [shipCertId], references: [id], onDelete: Cascade) - reviewer User? @relation("YswsReviewer", fields: [reviewerId], references: [id]) + devlogs String? @db.LongText + commits String? @db.LongText + decisions String? @db.LongText + reviewer User? @relation("YswsReviewer", fields: [reviewerId], references: [id]) + shipCert ShipCert @relation(fields: [shipCertId], references: [id], onDelete: Cascade) @@index([shipCertId]) @@index([status]) @@ -286,9 +283,9 @@ model Ticket { status Ticket_status? @default(open) createdAt DateTime? @default(now()) @db.Timestamp(0) closedAt DateTime? @db.Timestamp(0) - closedBy String? @db.VarChar(50) userAvatar String? @db.VarChar(500) assignees String? @db.Text + closedBy String? @db.VarChar(50) messages TicketMsg[] notes TicketNote[] @@ -311,17 +308,16 @@ model TicketFeedback { model TicketUser { id Int @id @default(autoincrement()) - userId String @db.VarChar(50) + userId String @unique @db.VarChar(50) isOptedIn Boolean @default(true) - @@unique([userId]) @@map("ticket_users") } model metricsHistory { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - output Json? + output String? @db.LongText @@map("metrics_history") } @@ -350,52 +346,44 @@ model TicketNote { authorId Int text String @db.Text createdAt DateTime @default(now()) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id]) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) @@index([ticketId]) + @@index([authorId], map: "ticket_notes_authorId_fkey") @@map("ticket_notes") } -enum Ticket_status { - open - closed -} - model SysLog { - id String @id @default(cuid()) + id String @id @default(cuid()) userId Int? slackId String? username String? role String? action String - context String? @db.Text + context String? @db.Text statusCode Int - ip String? - userAgent String? @db.Text - email String? + createdAt DateTime @default(now()) avatar String? + email String? + ip String? + userAgent String? @db.Text + metadata String? @db.LongText + severity String? @default("info") targetId Int? targetType String? - metadata Json? - severity String? @default("info") // not used anymore, leaving for old logs - + changes String? @db.LongText + duration Int? + errorMsg String? @db.Text + errorName String? + errorStack String? @db.Text + reqBody String? @db.LongText + reqHeaders String? @db.LongText reqMethod String? - reqUrl String? @db.Text - reqBody Json? - reqHeaders Json? + reqUrl String? @db.Text + resBody String? @db.LongText + resHeaders String? @db.LongText resStatus Int? - resBody Json? - resHeaders Json? - - errorName String? - errorMsg String? @db.Text - errorStack String? @db.Text - - changes Json? - duration Int? - - createdAt DateTime @default(now()) @@index([userId]) @@index([action]) @@ -408,35 +396,66 @@ model SysLog { } model SpotCheck { - id Int @id @default(autoincrement()) - caseId String @unique - certId Int + id Int @id @default(autoincrement()) + caseId String @unique staffId Int - reviewerId Int - - decision String - status String @default("unresolved") - - notes String? @db.Text - reasoning String? @db.Text - - lbRemoved Boolean @default(false) - fpReason String? @db.Text - + decision String + status String @default("unresolved") + notes String? @db.Text + reasoning String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt resolvedAt DateTime? resolvedBy Int? - - cert ShipCert @relation(fields: [certId], references: [id]) - staff User @relation("Staff", fields: [staffId], references: [id]) - reviewed User @relation("Reviewed", fields: [reviewerId], references: [id]) - resolver User? @relation("Resolved", fields: [resolvedBy], references: [id]) + certId Int + lbRemoved Boolean @default(false) + reviewerId Int + fpReason String? @db.Text + cert ShipCert @relation(fields: [certId], references: [id]) + resolver User? @relation("Resolved", fields: [resolvedBy], references: [id]) + reviewed User @relation("Reviewed", fields: [reviewerId], references: [id]) + staff User @relation("Staff", fields: [staffId], references: [id]) @@index([certId]) @@index([staffId]) @@index([reviewerId]) - @@index([reviewerId, createdAt]) @@index([status]) + @@index([resolvedBy], map: "spot_checks_resolvedBy_fkey") @@map("spot_checks") } + +model spot_check_session_certs { + id Int @id @default(autoincrement()) + sessionId Int + certId Int + addedAt DateTime @default(now()) + ship_certs ShipCert @relation(fields: [certId], references: [id], onDelete: Cascade) + spot_check_sessions spot_check_sessions @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@unique([sessionId, certId]) + @@index([certId]) + @@index([sessionId]) +} + +model spot_check_sessions { + id Int @id @default(autoincrement()) + staffId Int + startedAt DateTime @default(now()) + pausedAt DateTime? + endedAt DateTime? + totalSecondsAccrued Int @default(0) + status String @default("active") + wrightId Int? + spot_check_session_certs spot_check_session_certs[] + users_spot_check_sessions_staffIdTousers User @relation("spot_check_sessions_staffIdTousers", fields: [staffId], references: [id], onDelete: Cascade) + users_spot_check_sessions_wrightIdTousers User? @relation("spot_check_sessions_wrightIdTousers", fields: [wrightId], references: [id]) + + @@index([staffId]) + @@index([status]) + @@index([wrightId]) +} + +enum Ticket_status { + open + closed +} From 8e927a508eb1a0410e9aaf1a77ef28f2cd6db699 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:32:58 -0400 Subject: [PATCH 14/17] chore: add migration for captain role permissions and db schema sync --- .../migration.sql | 2 + .../migration.sql | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql create mode 100644 sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql diff --git a/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql b/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql new file mode 100644 index 0000000..277a4ac --- /dev/null +++ b/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql @@ -0,0 +1,2 @@ +-- Migration already applied. Placeholder for history tracking. +SELECT 1; diff --git a/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql b/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql new file mode 100644 index 0000000..314c4a0 --- /dev/null +++ b/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql @@ -0,0 +1,78 @@ +-- AlterTable +ALTER TABLE `users` MODIFY `skills` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `ysws_reviews` MODIFY `devlogs` LONGTEXT NULL, + MODIFY `commits` LONGTEXT NULL, + MODIFY `decisions` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `metrics_history` MODIFY `output` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `sys_logs` MODIFY `metadata` LONGTEXT NULL, + MODIFY `reqBody` LONGTEXT NULL, + MODIFY `reqHeaders` LONGTEXT NULL, + MODIFY `resBody` LONGTEXT NULL, + MODIFY `resHeaders` LONGTEXT NULL, + MODIFY `changes` LONGTEXT NULL; + +-- CreateTable +CREATE TABLE `spot_check_session_certs` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `sessionId` INTEGER NOT NULL, + `certId` INTEGER NOT NULL, + `addedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `spot_check_session_certs_certId_idx`(`certId`), + INDEX `spot_check_session_certs_sessionId_idx`(`sessionId`), + UNIQUE INDEX `spot_check_session_certs_sessionId_certId_key`(`sessionId`, `certId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `spot_check_sessions` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `staffId` INTEGER NOT NULL, + `startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `pausedAt` DATETIME(3) NULL, + `endedAt` DATETIME(3) NULL, + `totalSecondsAccrued` INTEGER NOT NULL DEFAULT 0, + `status` VARCHAR(191) NOT NULL DEFAULT 'active', + `wrightId` INTEGER NULL, + + INDEX `spot_check_sessions_staffId_idx`(`staffId`), + INDEX `spot_check_sessions_status_idx`(`status`), + INDEX `spot_check_sessions_wrightId_idx`(`wrightId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `payout_reqs_adminId_fkey` ON `payout_reqs`(`adminId`); + +-- CreateIndex +CREATE INDEX `sessions_userId_fkey` ON `sessions`(`userId`); + +-- CreateIndex +CREATE INDEX `ship_certs_claimerId_fkey` ON `ship_certs`(`claimerId`); + +-- CreateIndex +CREATE INDEX `ft_submitter_notes_staffId_fkey` ON `ft_submitter_notes`(`staffId`); + +-- CreateIndex +CREATE INDEX `ticket_notes_authorId_fkey` ON `ticket_notes`(`authorId`); + +-- CreateIndex +CREATE INDEX `spot_checks_resolvedBy_fkey` ON `spot_checks`(`resolvedBy`); + +-- AddForeignKey +ALTER TABLE `spot_check_session_certs` ADD CONSTRAINT `spot_check_session_certs_certId_fkey` FOREIGN KEY (`certId`) REFERENCES `ship_certs`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_session_certs` ADD CONSTRAINT `spot_check_session_certs_sessionId_fkey` FOREIGN KEY (`sessionId`) REFERENCES `spot_check_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_sessions` ADD CONSTRAINT `spot_check_sessions_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_sessions` ADD CONSTRAINT `spot_check_sessions_wrightId_fkey` FOREIGN KEY (`wrightId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; From 02ee80f10bee4ffc21329eda84fd9732f906d445 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:57:51 -0400 Subject: [PATCH 15/17] refactor: update skills handling and logging for JSON compatibility - Changed the way user skills are processed by parsing skills from JSON strings in the assignments and skills routes. - Updated the logging function to serialize metadata, request, and response bodies and headers to JSON format, ensuring consistent data handling. --- sw-dash/src/app/api/admin/assignments/route.ts | 2 +- sw-dash/src/app/api/admin/skills/route.ts | 8 ++++---- sw-dash/src/lib/log.ts | 13 ++++++------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/sw-dash/src/app/api/admin/assignments/route.ts b/sw-dash/src/app/api/admin/assignments/route.ts index aae0c02..d7e4bbb 100644 --- a/sw-dash/src/app/api/admin/assignments/route.ts +++ b/sw-dash/src/app/api/admin/assignments/route.ts @@ -72,7 +72,7 @@ export const POST = api()(async ({ user, req }) => { }) let reviewers = allUsers.filter((u) => { - const userSkills = (u.skills as string[]) || [] + const userSkills = JSON.parse(u.skills || '[]') as string[] return types.some((t: string) => userSkills.includes(t)) }) diff --git a/sw-dash/src/app/api/admin/skills/route.ts b/sw-dash/src/app/api/admin/skills/route.ts index af69ca3..a710fd7 100644 --- a/sw-dash/src/app/api/admin/skills/route.ts +++ b/sw-dash/src/app/api/admin/skills/route.ts @@ -14,7 +14,7 @@ export const GET = api()(async ({ user }) => { return NextResponse.json({ error: 'user not found' }, { status: 404 }) } - const userSkills = (dbUser.skills as string[]) || [] + const userSkills = JSON.parse(dbUser.skills || '[]') as string[] return NextResponse.json({ skills: userSkills, available: SKILLS }) } catch { @@ -43,19 +43,19 @@ export const POST = api()(async ({ user, req }) => { select: { skills: true }, }) - const current = (dbUser?.skills as string[]) || [] + const current = JSON.parse(dbUser?.skills || '[]') as string[] if (action === 'add') { if (!current.includes(skill)) { await prisma.user.update({ where: { id: user.id }, - data: { skills: [...current, skill] }, + data: { skills: JSON.stringify([...current, skill]) }, }) } } else { await prisma.user.update({ where: { id: user.id }, - data: { skills: current.filter((s) => s !== skill) }, + data: { skills: JSON.stringify(current.filter((s) => s !== skill)) }, }) } diff --git a/sw-dash/src/lib/log.ts b/sw-dash/src/lib/log.ts index 9447eee..16a9286 100644 --- a/sw-dash/src/lib/log.ts +++ b/sw-dash/src/lib/log.ts @@ -1,5 +1,4 @@ import { prisma } from './db' -import { Prisma } from '@prisma/client' interface User { id?: number @@ -56,18 +55,18 @@ export async function log(data: LogData) { userAgent: data.meta?.ua, targetId: data.target?.id, targetType: data.target?.type, - metadata: (data.meta || null) as Prisma.InputJsonValue, + metadata: data.meta ? JSON.stringify(data.meta) : null, reqMethod: data.req?.method, reqUrl: data.req?.url, - reqBody: data.req?.body as Prisma.InputJsonValue, - reqHeaders: data.req?.headers as Prisma.InputJsonValue, + reqBody: data.req?.body !== undefined ? JSON.stringify(data.req.body) : null, + reqHeaders: data.req?.headers ? JSON.stringify(data.req.headers) : null, resStatus: data.res?.status, - resBody: data.res?.body as Prisma.InputJsonValue, - resHeaders: data.res?.headers as Prisma.InputJsonValue, + resBody: data.res?.body !== undefined ? JSON.stringify(data.res.body) : null, + resHeaders: data.res?.headers ? JSON.stringify(data.res.headers) : null, errorName: data.error?.name, errorMsg: data.error?.message, errorStack: data.error?.stack, - changes: data.changes as Prisma.InputJsonValue, + changes: data.changes ? JSON.stringify(data.changes) : null, }, }) } catch (e) { From 535e0b9cfad666636b45a45b27309b003e354728 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:10:01 -0400 Subject: [PATCH 16/17] refactor: standardize JSON handling for skills and decisions across routes - Updated skills handling in the assignments and users routes to ensure skills are stored as JSON strings. - Modified the ysws_reviews routes to consistently parse and stringify decisions, improving data integrity. - Enhanced the refresh route to handle existing decisions as JSON, ensuring compatibility with the updated structure. --- sw-dash/src/app/api/admin/assignments/route.ts | 2 +- .../app/api/admin/ship_certifications/[id]/route.ts | 12 +++++------- sw-dash/src/app/api/admin/users/[id]/skills/route.ts | 2 +- .../app/api/admin/ysws_reviews/[id]/refresh/route.ts | 8 ++++---- sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts | 6 +++--- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/sw-dash/src/app/api/admin/assignments/route.ts b/sw-dash/src/app/api/admin/assignments/route.ts index d7e4bbb..682d50d 100644 --- a/sw-dash/src/app/api/admin/assignments/route.ts +++ b/sw-dash/src/app/api/admin/assignments/route.ts @@ -89,7 +89,7 @@ export const POST = api()(async ({ user, req }) => { }) if (selfUser) { - const selfSkills = (selfUser.skills as string[]) || [] + const selfSkills = JSON.parse(selfUser.skills || '[]') as string[] if (types.some((t: string) => selfSkills.includes(t))) { reviewers = [selfUser] } diff --git a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts index a8c5d2e..d1ed2b5 100644 --- a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts +++ b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts @@ -53,8 +53,6 @@ export const GET = withParams(PERMS.certs_view)(async ({ user, params }) => { }, }, }, - orderBy: { createdAt: 'desc' }, - take: 1, }, }, }) @@ -127,12 +125,12 @@ export const GET = withParams(PERMS.certs_view)(async ({ user, params }) => { } : null, syncedToFt: cert.syncedToFt, - assignment: cert.assignments[0] + assignment: cert.assignments ? { - id: cert.assignments[0].id, - status: cert.assignments[0].status, - assignee: cert.assignments[0].assignee?.username || null, - createdAt: cert.assignments[0].createdAt.toISOString(), + id: cert.assignments.id, + status: cert.assignments.status, + assignee: cert.assignments.assignee?.username || null, + createdAt: cert.assignments.createdAt.toISOString(), } : null, notes: internalNotes.map((note) => ({ diff --git a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts index 6d15b8e..112e2ba 100644 --- a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts +++ b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts @@ -67,7 +67,7 @@ export const POST = withParams()(async ({ user, req, params, ip, ua }) => { await prisma.user.update({ where: { id: userId }, - data: { skills: skills }, + data: { skills: JSON.stringify(skills) }, }) if (!isSelf) { diff --git a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts index 53d2e3f..4745235 100644 --- a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts +++ b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts @@ -47,7 +47,7 @@ export const POST = yswsApiWithParams(PERMS.ysws_edit)(async ({ params, user }) .filter((d) => d.created_at) .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) - const existingDecisions = (review.decisions as any[]) || [] + const existingDecisions = JSON.parse(review.decisions || '[]') as any[] const devlogs: any[] = [] const commits: any[] = [] @@ -124,9 +124,9 @@ export const POST = yswsApiWithParams(PERMS.ysws_edit)(async ({ params, user }) await prisma.yswsReview.update({ where: { id: yswsId }, data: { - devlogs: JSON.parse(JSON.stringify(devlogs)), - commits: JSON.parse(JSON.stringify(commits)), - decisions: JSON.parse(JSON.stringify(decisions)), + devlogs: JSON.stringify(devlogs), + commits: JSON.stringify(commits), + decisions: JSON.stringify(decisions), }, }) diff --git a/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts b/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts index 31a9b73..b8ec680 100644 --- a/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts +++ b/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts @@ -56,7 +56,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({ status: 'pending', reviewerId: null, returnReason: null, - decisions: [], + decisions: '[]', }, }) @@ -93,7 +93,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({ }) if (!review) return NextResponse.json({ error: 'not found' }, { status: 404 }) - let decisions = (review.decisions as Decision[] | null) || [] + let decisions = JSON.parse(review.decisions || '[]') as Decision[] if (updates && Array.isArray(updates)) { decisions = decisions.map((d) => { @@ -116,7 +116,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({ status: action === 'complete' ? 'done' : 'returned', returnReason: action === 'return' ? returnReason : null, reviewerId: user.id, - decisions: JSON.parse(JSON.stringify(decisions)), + decisions: JSON.stringify(decisions), }, }) From 017d161e3332a59fdd0a7f61a16d9d85c7958ab5 Mon Sep 17 00:00:00 2001 From: Dhamari Trice-Hanson <39872667+dhamariT@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:43:53 -0400 Subject: [PATCH 17/17] refactor: improve JSON parsing for skills and decisions in API routes - Updated skills handling in the users route to ensure skills are parsed from JSON strings. - Enhanced the refresh route in ysws_reviews to consistently parse decisions as JSON, improving data integrity and compatibility. --- sw-dash/src/app/api/admin/users/[id]/skills/route.ts | 2 +- sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts index 112e2ba..e572cfc 100644 --- a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts +++ b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts @@ -27,7 +27,7 @@ export const GET = withParams()(async ({ user, params }) => { return NextResponse.json({ error: 'who dat' }, { status: 404 }) } - return NextResponse.json({ skills: target.skills || [] }) + return NextResponse.json({ skills: JSON.parse(target.skills || '[]') }) } catch { return NextResponse.json({ error: 'couldnt grab skills' }, { status: 500 }) } diff --git a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts index 4745235..9523111 100644 --- a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts +++ b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts @@ -47,7 +47,7 @@ export const POST = yswsApiWithParams(PERMS.ysws_edit)(async ({ params, user }) .filter((d) => d.created_at) .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) - const existingDecisions = JSON.parse(review.decisions || '[]') as any[] + const existingDecisions = JSON.parse((review.decisions as string) || '[]') as any[] const devlogs: any[] = [] const commits: any[] = []