diff --git a/client/src/components/FlatFileImportPanel.tsx b/client/src/components/FlatFileImportPanel.tsx index f009168..f151c8d 100644 --- a/client/src/components/FlatFileImportPanel.tsx +++ b/client/src/components/FlatFileImportPanel.tsx @@ -10,7 +10,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { useRef, useState } from 'react'; import { DataTable } from '@/shared/dataTable.tsx'; -type ImportDatasetId = +export type ImportDatasetId = | 'active-projects' | 'all-projects' | 'assistance-listing-numbers'; @@ -125,7 +125,7 @@ interface HistoricalValidationErrorRow { sourceHeader: string; } -const datasetOptions: ImportDatasetOption[] = [ +export const datasetOptions: ImportDatasetOption[] = [ { id: 'all-projects', label: 'All Projects' }, { id: 'active-projects', label: 'Active Projects' }, { @@ -335,6 +335,189 @@ export function FlatFileImportPanel() { ); } +export function FlatFileImportChecklistItem({ + completed, + dataset, + latestImport, + markDonePending, + onMarkDone, + ready, + stale, + staleReason, +}: { + completed: boolean; + dataset: ImportDatasetId; + latestImport?: RecentImportResponse | null; + markDonePending: boolean; + onMarkDone: () => void; + ready: boolean; + stale: boolean; + staleReason?: string | null; +}) { + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + const [file, setFile] = useState(null); + const [selectedImportId, setSelectedImportId] = useState(null); + const [validation, setValidation] = useState( + null + ); + const [success, setSuccess] = useState(null); + const importDetailQuery = useQuery({ + enabled: selectedImportId !== null, + queryFn: () => fetchImportDetail(selectedImportId!), + queryKey: ['imports', 'detail', selectedImportId], + }); + const label = getDatasetLabel(dataset); + + const clearInputFileSelection = () => { + setFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const mutation = useMutation({ + mutationFn: uploadImport, + onError: (error, variables) => { + setSuccess(null); + if (error instanceof HttpError && isValidationResponse(error.body)) { + setValidation(error.body); + return; + } + + setValidation({ + attemptedRows: 0, + dataset: variables.dataset, + fileErrors: [ + { + code: 'upload_failed', + message: 'The import could not be completed.', + }, + ], + filename: variables.file.name, + rows: [], + succeeded: false, + }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['imports', 'recent'] }); + void queryClient.invalidateQueries({ + queryKey: ['projectIdentification', 'setup'], + }); + }, + onSuccess: (response) => { + setValidation(null); + setSuccess(response); + setSelectedImportId(response.importLogId ?? null); + void queryClient.invalidateQueries({ queryKey: ['ad419Workflow'] }); + }, + }); + + const canUpload = Boolean(file) && !mutation.isPending; + const canMarkDone = ready && !completed && !markDonePending; + + const handleUpload = () => { + if (!file || mutation.isPending) { + return; + } + + const selectedFile = file; + clearInputFileSelection(); + setValidation(null); + setSuccess(null); + setSelectedImportId(null); + mutation.mutate({ dataset, file: selectedFile }); + }; + + return ( +
+ {latestImport ? ( + + ) : ( +
+ No import attempts found for {label}. +
+ )} + + {stale && staleReason ? ( +
+ {staleReason} +
+ ) : null} + +
+ + + + + +
+ + {selectedImportId !== null && ( + + )} + + {success && ( +
+ + Imported {success.rowsImported.toLocaleString()} rows from{' '} + {success.filename}. Mark this checklist item done when you are + ready to use this import. + +
+ )} + + {validation && } +
+ ); +} + function RecentImportSummaryList({ isError, isLoading, @@ -629,6 +812,10 @@ function getImportSummaries( }); } +function getDatasetLabel(dataset: ImportDatasetId): string { + return datasetOptions.find((option) => option.id === dataset)?.label ?? dataset; +} + function getImportStatusPresentation(status: string) { switch (status) { case 'Succeeded': diff --git a/client/src/components/ProjectIdentificationSetupChecklist.tsx b/client/src/components/ProjectIdentificationSetupChecklist.tsx new file mode 100644 index 0000000..5284da1 --- /dev/null +++ b/client/src/components/ProjectIdentificationSetupChecklist.tsx @@ -0,0 +1,479 @@ +import { + confirmFiscalPeriod, + setChecklistItemCompletion, + type ProjectChecklistItem, + type ProjectIdentificationSetupResponse, +} from '@/queries/projectIdentification.ts'; +import { + FlatFileImportChecklistItem, + type ImportDatasetId, +} from '@/components/FlatFileImportPanel.tsx'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { fetchJson } from '@/lib/api.ts'; + +interface PgmImportResponse { + reportDate: string; + rowsImported: number; +} + +export function ProjectIdentificationSetupChecklist({ + issueCount, + setup, +}: { + issueCount: number | null; + setup: ProjectIdentificationSetupResponse; +}) { + const queryClient = useQueryClient(); + const firstIncompleteId = useMemo( + () => setup.checklistItems.find((item) => !item.completed)?.id ?? null, + [setup.checklistItems] + ); + const [openItemId, setOpenItemId] = useState( + firstIncompleteId + ); + + const markCompletionMutation = useMutation({ + mutationFn: ({ + completed, + itemId, + }: { + completed: boolean; + itemId: string; + }) => setChecklistItemCompletion(itemId, completed), + onSuccess: (response) => { + queryClient.setQueryData(['projectIdentification', 'setup'], response); + setOpenItemId(findFirstIncompleteItemId(response)); + void queryClient.invalidateQueries({ queryKey: ['projectList'] }); + }, + }); + + const fiscalMutation = useMutation({ + mutationFn: confirmFiscalPeriod, + onSuccess: (response) => { + queryClient.setQueryData(['projectIdentification', 'setup'], response); + setOpenItemId(findFirstIncompleteItemId(response)); + void queryClient.invalidateQueries({ queryKey: ['projectList'] }); + }, + }); + + const handleMarkDone = (itemId: string) => { + markCompletionMutation.mutate({ completed: true, itemId }); + }; + + return ( +
+
+
+

Setup checklist

+

Load required data

+
+
+
+ {setup.completedCount} of {setup.totalCount} complete +
+ +
+
+ +
+ {setup.checklistItems.map((item) => { + const locked = item.status === 'locked'; + const open = openItemId === item.id && !locked; + const pending = + markCompletionMutation.isPending && + markCompletionMutation.variables?.itemId === item.id; + + return ( +
+ + + {open ? ( +
+ + fiscalMutation.mutate(fiscalYear) + } + onMarkDone={() => handleMarkDone(item.id)} + setup={setup} + /> +
+ ) : null} +
+ ); + })} +
+
+ ); +} + +function ChecklistItemContent({ + fiscalMutationPending, + issueCount, + item, + markDonePending, + onConfirmFiscalYear, + onMarkDone, + setup, +}: { + fiscalMutationPending: boolean; + issueCount: number | null; + item: ProjectChecklistItem; + markDonePending: boolean; + onConfirmFiscalYear: (fiscalYear: string) => void; + onMarkDone: () => void; + setup: ProjectIdentificationSetupResponse; +}) { + if (item.kind === 'select') { + return ( + + ); + } + + if (item.kind === 'upload') { + return ( + + ); + } + + if (item.kind === 'import') { + return ( + + ); + } + + if (item.kind === 'review') { + const issuesResolved = issueCount === 0; + + return ( +
+

+ {issueCount === null + ? 'Project issues are still being checked. Review can be marked complete after the project list loads.' + : issueCount > 0 + ? `${issueCount.toLocaleString()} ${ + issueCount === 1 ? 'project needs' : 'projects need' + } review in the table below. Each issue must be resolved or explicitly accepted before finalizing.` + : 'No project issues were detected. You can mark this checklist item reviewed.'} +

+ +
+ ); + } + + return ( +
+

+ Finalizing will be enabled when project issue acceptance and expense + pull behavior are implemented. +

+ +
+ ); +} + +function FiscalPeriodChecklistContent({ + item, + onConfirmFiscalYear, + pending, + setup, +}: { + item: ProjectChecklistItem; + onConfirmFiscalYear: (fiscalYear: string) => void; + pending: boolean; + setup: ProjectIdentificationSetupResponse; +}) { + const [selectedFiscalYear, setSelectedFiscalYear] = useState( + setup.fiscalYear + ); + const selectedOption = + setup.fiscalPeriodOptions.find( + (option) => option.fiscalYear === selectedFiscalYear + ) ?? setup.fiscalPeriodOptions[0]; + + return ( +
+
+ + + +
+ +
+ +
+
+ ); +} + +function PgmImportChecklistContent({ + item, + markDonePending, + onMarkDone, + setup, +}: { + item: ProjectChecklistItem; + markDonePending: boolean; + onMarkDone: () => void; + setup: ProjectIdentificationSetupResponse; +}) { + const queryClient = useQueryClient(); + const [result, setResult] = useState(null); + const mutation = useMutation({ + mutationFn: () => + fetchJson( + `/api/pgmprojects/import?reportDate=${encodeURIComponent( + setup.cycleEnd + )}`, + { + method: 'POST', + } + ), + onSuccess: (response) => { + setResult(response); + void queryClient.invalidateQueries({ + queryKey: ['projectIdentification', 'setup'], + }); + void queryClient.invalidateQueries({ queryKey: ['projectList'] }); + }, + }); + const rows = result?.rowsImported ?? item.source?.rows ?? null; + const importedAt = item.source?.completedAt; + const canMarkDone = item.ready && !item.completed && !markDonePending; + + return ( +
+ {rows ? ( +
+
PGM Master Data
+
+ {rows.toLocaleString()} rows imported + {importedAt ? ` on ${formatDate(importedAt)}` : ''}. +
+
+ ) : ( +

+ Pulls AE Redshift PGM master data for the selected reporting cycle. +

+ )} + + {mutation.isError ? ( +
+ PGM master data could not be imported. +
+ ) : null} + +

+ This import may take several minutes. Do not reload your browser while it + runs. +

+ +
+ + +
+
+ ); +} + +function ChecklistMeta({ + issueCount, + item, +}: { + issueCount: number | null; + item: ProjectChecklistItem; +}) { + if (item.kind === 'review') { + if (issueCount === null) { + return Checking; + } + + return ( + 0 ? 'badge badge-warning' : 'badge badge-success' + } + > + {issueCount > 0 ? `${issueCount.toLocaleString()} issues` : 'No issues'} + + ); + } + + if (item.kind === 'upload' && item.latestImport) { + const className = + item.latestImport.status === 'Succeeded' + ? 'badge badge-success' + : 'badge badge-error'; + const rowCount = + item.latestImport.status === 'Succeeded' + ? item.latestImport.rowsImported + : item.latestImport.attemptedRows; + + return ( + {rowCount?.toLocaleString() ?? 0} rows + ); + } + + if (item.kind === 'import' && item.source?.rows) { + return ( + + {item.source.rows.toLocaleString()} rows + + ); + } + + if (item.status === 'ready') { + return Ready; + } + + if (item.status === 'stale') { + return Needs review; + } + + return null; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +function findFirstIncompleteItemId( + setup: ProjectIdentificationSetupResponse +): string | null { + return setup.checklistItems.find((item) => !item.completed)?.id ?? null; +} diff --git a/client/src/components/ProjectIdentificationStage.tsx b/client/src/components/ProjectIdentificationStage.tsx new file mode 100644 index 0000000..6276071 --- /dev/null +++ b/client/src/components/ProjectIdentificationStage.tsx @@ -0,0 +1,298 @@ +import { useMemo, useState } from 'react'; +import { DataTable } from '@/shared/dataTable.tsx'; +import { + projectListQueryOptions, + type ProjectListRow, + type ProjectListStatus, + type ProjectListSummary, +} from '@/queries/projectList.ts'; +import { useQuery } from '@tanstack/react-query'; +import type { ColumnDef } from '@tanstack/react-table'; +import { + projectIdentificationSetupQueryOptions, + type ProjectIdentificationSetupResponse, +} from '@/queries/projectIdentification.ts'; +import { ProjectIdentificationSetupChecklist } from '@/components/ProjectIdentificationSetupChecklist.tsx'; + +type ProjectListTab = 'issues' | 'clean' | 'all'; + +const tabs: { id: ProjectListTab; label: string }[] = [ + { id: 'issues', label: 'Issues' }, + { id: 'clean', label: 'Clean' }, + { id: 'all', label: 'All' }, +]; + +function displayValue(value: string | null): string { + return value && value.trim() ? value : '-'; +} + +function displayStatus(status: ProjectListStatus): string { + return status === '204 outside college' ? '204 outside CAES' : status; +} + +function statusClassName(status: ProjectListStatus): string { + if (status === 'Clean') { + return 'badge badge-success badge-outline'; + } + + if (status === '204 outside college' || status === 'SFN mismatch') { + return 'badge badge-warning'; + } + + return 'badge badge-error badge-outline'; +} + +function rowsForTab(rows: ProjectListRow[], tab: ProjectListTab) { + if (tab === 'issues') { + return rows.filter((row) => row.status !== 'Clean'); + } + + if (tab === 'clean') { + return rows.filter((row) => row.status === 'Clean'); + } + + return rows; +} + +function sfnDistributionText(summary: ProjectListSummary): string { + if (summary.sfnDistribution.length === 0) { + return '-'; + } + + return summary.sfnDistribution + .map((item) => `${item.sfn}: ${item.count}`) + .join(' / '); +} + +export function ProjectIdentificationStage() { + const setupQuery = useQuery(projectIdentificationSetupQueryOptions()); + + if (setupQuery.isLoading) { + return

Loading project identification setup...

; + } + + if (setupQuery.isError || !setupQuery.data) { + return ( +
+
+

Unable to load project setup

+

The project identification checklist could not be loaded.

+ +
+
+ ); + } + + return ; +} + +function ProjectIdentificationStageContent({ + setup, +}: { + setup: ProjectIdentificationSetupResponse; +}) { + const { data, error, isError, isFetching, isLoading, refetch } = useQuery( + projectListQueryOptions(setup.fiscalYear) + ); + const [activeTab, setActiveTab] = useState('issues'); + + const columns = useMemo[]>( + () => [ + { + accessorFn: (row) => row.nifaProject ?? '', + cell: ({ row }) => displayValue(row.original.nifaProject), + header: 'NIFA Project', + id: 'nifaProject', + }, + { + accessorFn: (row) => row.accession ?? '', + cell: ({ row }) => displayValue(row.original.accession), + header: 'Accession', + id: 'accession', + }, + { + accessorFn: (row) => row.awardNumber ?? '', + cell: ({ row }) => displayValue(row.original.awardNumber), + header: 'Award #', + id: 'awardNumber', + }, + { + accessorFn: (row) => row.ae ?? '', + cell: ({ row }) => displayValue(row.original.ae), + header: 'AE', + id: 'ae', + }, + { + accessorFn: (row) => row.pi ?? '', + cell: ({ row }) => displayValue(row.original.pi), + header: 'PI', + id: 'pi', + }, + { + accessorFn: (row) => row.orgr ?? '', + cell: ({ row }) => displayValue(row.original.orgr), + header: 'ORGR', + id: 'orgr', + }, + { + accessorFn: (row) => row.sfn ?? '', + cell: ({ row }) => displayValue(row.original.sfn), + header: 'SFN', + id: 'sfn', + }, + { + accessorFn: (row) => displayStatus(row.status), + cell: ({ row }) => ( + + {displayStatus(row.original.status)} + + ), + header: 'Status', + id: 'status', + }, + ], + [] + ); + + const pgmItem = setup.checklistItems.find( + (item) => item.id === 'pgm-master-data' + ); + const projectListReady = pgmItem?.completed ?? false; + const issueCount = data?.summary.issuesToResolve ?? null; + + if (isError) { + const message = + error instanceof Error + ? error.message + : 'The project list could not be loaded.'; + + return ( +
+ +
+
+

Unable to load project list

+

{message}

+ +
+
+
+ ); + } + + const visibleRows = data ? rowsForTab(data.rows, activeTab) : []; + + return ( +
+ {data ? : null} + + + +
+
+
+

+ Reference & Issue Resolution +

+

+ Project list{data ? ` · ${data.counts.all}` : ''} +

+
+
+ +
+ {isLoading ? ( +

Loading project list...

+ ) : !projectListReady ? ( +
+ Project list will appear after PGM master data is imported and + marked done. +
+ ) : data ? ( + <> +
+ {tabs.map((tab) => { + const count = data.counts[tab.id]; + + return ( + + ); + })} +
+ + + + ) : null} +
+
+
+ ); +} + +function ProjectSummaryCards({ summary }: { summary: ProjectListSummary }) { + const summaryCards = [ + ['Active NIFA', summary.activeNifa], + ['All NIFA', summary.allNifa], + ['PGM records', summary.pgmRecords], + ['ALN codes', summary.alnCodes], + ['Issues to resolve', summary.issuesToResolve], + ['SFN distribution', sfnDistributionText(summary)], + ]; + + return ( +
+
+ {summaryCards.map(([label, value]) => ( +
+
+ {label} +
+
{value}
+
+ ))} +
+
+ ); +} diff --git a/client/src/main.css b/client/src/main.css index 63c994b..2b4aa2c 100644 --- a/client/src/main.css +++ b/client/src/main.css @@ -102,4 +102,39 @@ .workflow-panel__actions { @apply flex flex-wrap items-center gap-2; } + + .checklist-item { + @apply bg-white; + } + + .checklist-item--active, + .checklist-item--ready, + .checklist-item--stale { + @apply bg-slate-50; + } + + .checklist-item__header { + @apply flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60; + } + + .checklist-item__bullet { + @apply flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-slate-300 text-sm font-bold text-slate-700; + } + + .checklist-item--done .checklist-item__bullet { + @apply border-slate-950 bg-slate-950 text-white; + } + + .checklist-item--active .checklist-item__bullet, + .checklist-item--ready .checklist-item__bullet { + @apply border-sky-700 bg-sky-700 text-white; + } + + .checklist-item--stale .checklist-item__bullet { + @apply border-amber-500 bg-amber-500 text-white; + } + + .checklist-item__content { + @apply border-t border-dashed border-slate-200 px-4 py-4 sm:pl-14; + } } diff --git a/client/src/queries/projectIdentification.ts b/client/src/queries/projectIdentification.ts new file mode 100644 index 0000000..5350160 --- /dev/null +++ b/client/src/queries/projectIdentification.ts @@ -0,0 +1,106 @@ +import { fetchJson } from '@/lib/api.ts'; +import { queryOptions } from '@tanstack/react-query'; + +export type ChecklistStatus = 'active' | 'done' | 'locked' | 'ready' | 'stale'; + +export type ChecklistKind = 'action' | 'import' | 'review' | 'select' | 'upload'; + +export interface RecentImportResponse { + attemptedRows: number; + dataset: string; + filename: string; + id: number; + importedAt: string; + rowsImported?: number | null; + status: string; + uploadedByEmail?: string | null; + uploadedByName?: string | null; + validationStats?: ImportValidationStatsResponse | null; +} + +export interface ImportValidationStatsResponse { + errorCount: number; + fileErrorCount: number; + rowCount: number; + rowsWithErrors: number; +} + +export interface ChecklistSource { + completedAt?: string | null; + importLogId?: number | null; + key?: string | null; + rows?: number | null; +} + +export interface ProjectChecklistItem { + completed: boolean; + hint: string; + id: string; + kind: ChecklistKind; + label: string; + latestImport?: RecentImportResponse | null; + number: number; + ready: boolean; + source?: ChecklistSource | null; + stale: boolean; + staleReason?: string | null; + status: ChecklistStatus; +} + +export interface FiscalPeriodOption { + cycleEnd: string; + cycleStart: string; + fiscalYear: string; + label: string; +} + +export interface ProjectIdentificationSetupResponse { + checklistItems: ProjectChecklistItem[]; + completedCount: number; + cycleEnd: string; + cycleStart: string; + fiscalPeriodOptions: FiscalPeriodOption[]; + fiscalYear: string; + totalCount: number; + workflowRunId: number; +} + +export const projectIdentificationSetupQueryOptions = () => + queryOptions({ + queryFn: ({ signal }) => + fetchJson( + '/api/projectidentification/setup', + {}, + signal + ), + queryKey: ['projectIdentification', 'setup'] as const, + }); + +export async function confirmFiscalPeriod(fiscalYear: string) { + return fetchJson( + '/api/projectidentification/fiscal-period', + { + body: JSON.stringify({ fiscalYear }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'PUT', + } + ); +} + +export async function setChecklistItemCompletion( + itemId: string, + completed: boolean +) { + return fetchJson( + `/api/projectidentification/checklist/${encodeURIComponent(itemId)}`, + { + body: JSON.stringify({ completed }), + headers: { + 'Content-Type': 'application/json', + }, + method: 'PUT', + } + ); +} diff --git a/client/src/queries/projectList.ts b/client/src/queries/projectList.ts new file mode 100644 index 0000000..52d5652 --- /dev/null +++ b/client/src/queries/projectList.ts @@ -0,0 +1,68 @@ +import { fetchJson } from '@/lib/api.ts'; +import { queryOptions } from '@tanstack/react-query'; + +export type ProjectListStatus = + | '204 outside college' + | 'Clean' + | 'Expired' + | 'No PGM match' + | 'Not in All Projects' + | 'SFN mismatch' + | string; + +export interface ProjectListRow { + accession: string | null; + ae: string | null; + awardNumber: string | null; + nifaProject: string | null; + orgr: string | null; + pi: string | null; + sfn: string | null; + status: ProjectListStatus; +} + +export interface ProjectListCounts { + all: number; + clean: number; + issues: number; +} + +export interface SfnDistribution { + count: number; + sfn: string; +} + +export interface ProjectListSummary { + activeNifa: number; + allNifa: number; + alnCodes: number; + issuesToResolve: number; + pgmRecords: number; + sfnDistribution: SfnDistribution[]; +} + +export interface ProjectListResponse { + counts: ProjectListCounts; + cycleEnd: string; + cycleStart: string; + fiscalYear: string; + rows: ProjectListRow[]; + summary: ProjectListSummary; +} + +export function currentFiscalYear(date = new Date()): string { + const calendarYear = date.getFullYear(); + const fiscalYear = date.getMonth() >= 9 ? calendarYear + 1 : calendarYear; + return `FY${String(fiscalYear % 100).padStart(2, '0')}`; +} + +export const projectListQueryOptions = (fiscalYear = currentFiscalYear()) => + queryOptions({ + queryFn: ({ signal }) => + fetchJson( + `/api/projectlist?fy=${encodeURIComponent(fiscalYear)}`, + {}, + signal + ), + queryKey: ['projectList', fiscalYear] as const, + }); diff --git a/client/src/routes/(authenticated)/workflow.$stageId.tsx b/client/src/routes/(authenticated)/workflow.$stageId.tsx index 3f0b8a8..b54eb14 100644 --- a/client/src/routes/(authenticated)/workflow.$stageId.tsx +++ b/client/src/routes/(authenticated)/workflow.$stageId.tsx @@ -4,8 +4,8 @@ import { } from '@/mockData.ts'; import { workflowSnapshotQueryOptions } from '@/queries.ts'; import { DataClassificationStage } from '@/components/dataClassification/DataClassificationStage.tsx'; +import { ProjectIdentificationStage } from '@/components/ProjectIdentificationStage.tsx'; import { SectionPanel } from '@/components/SectionPanel.tsx'; -import { FlatFileImportPanel } from '@/components/FlatFileImportPanel.tsx'; import { WorkflowShell } from '@/components/WorkflowShell.tsx'; import type { WorkflowStageId } from '@/types.ts'; import type { RouterContext } from '@/main.tsx'; @@ -48,9 +48,7 @@ function WorkflowStageRoute() {
{workflowStageId === 'project-identification' ? ( - - - + ) : workflowStageId === 'data-classification' ? ( ) : ( diff --git a/client/src/test/components/FlatFileImportPanel.test.tsx b/client/src/test/components/FlatFileImportPanel.test.tsx index e6b9af8..9230e40 100644 --- a/client/src/test/components/FlatFileImportPanel.test.tsx +++ b/client/src/test/components/FlatFileImportPanel.test.tsx @@ -601,11 +601,11 @@ describe('FlatFileImportPanel', () => { ], errors: [], rowNum: 2, - values: { - ProjectNumber: 'PRJ-1', - AccessionNumber: 'A-2', - OrganizationName: '', - }, + values: Object.fromEntries([ + ['ProjectNumber', 'PRJ-1'], + ['AccessionNumber', 'A-2'], + ['OrganizationName', ''], + ]), }, ], succeeded: false, diff --git a/client/src/test/routes/(authenticated)/workflow.test.tsx b/client/src/test/routes/(authenticated)/workflow.test.tsx index 867ba13..7fc6b79 100644 --- a/client/src/test/routes/(authenticated)/workflow.test.tsx +++ b/client/src/test/routes/(authenticated)/workflow.test.tsx @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { screen, waitFor } from '@testing-library/react'; import { server } from '@/test/mswUtils.ts'; import { renderRoute } from '@/test/routerUtils.tsx'; +import { userEvent } from '@testing-library/user-event'; const mockUser = { email: 'shannon@example.edu', @@ -11,14 +12,220 @@ const mockUser = { roles: ['User'], }; +const projectListResponse = { + counts: { + all: 3, + clean: 1, + issues: 2, + }, + cycleEnd: '2026-09-30', + cycleStart: '2025-10-01', + fiscalYear: 'FY26', + rows: [ + { + accession: '1053852', + ae: 'K1234', + awardNumber: '2025-111', + nifaProject: 'CA-A-111-H', + orgr: 'ATM', + pi: 'Larkspur, S.', + sfn: '201', + status: 'Clean', + }, + { + accession: '1055356', + ae: 'K2222', + awardNumber: '2025-222', + nifaProject: 'CA-B-222-CG', + orgr: 'ANS', + pi: 'Okonkwo, Y.', + sfn: '204', + status: '204 outside college', + }, + { + accession: '1078258', + ae: null, + awardNumber: '2025-333', + nifaProject: 'CA-C-333-CG', + orgr: 'VEN', + pi: 'Naidoo, T.', + sfn: '204', + status: 'No PGM match', + }, + ], + summary: { + activeNifa: 25, + allNifa: 42, + alnCodes: 7, + issuesToResolve: 2, + pgmRecords: 18, + sfnDistribution: [ + { count: 1, sfn: '201' }, + { count: 2, sfn: '204' }, + ], + }, +}; + +const setupResponse = { + checklistItems: [ + { + completed: true, + hint: 'Set the reporting cycle', + id: 'fiscal-period', + kind: 'select', + label: 'Confirm Fiscal Period', + number: 1, + ready: true, + source: null, + stale: false, + staleReason: null, + status: 'done', + }, + { + completed: true, + hint: 'ANR - all NIFA projects across UC campuses', + id: 'all-projects', + kind: 'upload', + label: 'Upload All Projects List', + latestImport: { + attemptedRows: 42, + dataset: 'all-projects', + filename: 'all-projects.csv', + id: 1, + importedAt: '2026-06-01T12:00:00Z', + rowsImported: 42, + status: 'Succeeded', + }, + number: 2, + ready: true, + source: { completedAt: '2026-06-01T12:00:00Z', importLogId: 1, rows: 42 }, + stale: false, + staleReason: null, + status: 'done', + }, + { + completed: true, + hint: 'ANR - CAES projects required to report', + id: 'active-projects', + kind: 'upload', + label: 'Upload Active Project List', + latestImport: { + attemptedRows: 25, + dataset: 'active-projects', + filename: 'active-projects.csv', + id: 2, + importedAt: '2026-06-01T12:01:00Z', + rowsImported: 25, + status: 'Succeeded', + }, + number: 3, + ready: true, + source: { completedAt: '2026-06-01T12:01:00Z', importLogId: 2, rows: 25 }, + stale: false, + staleReason: null, + status: 'done', + }, + { + completed: true, + hint: 'assistancelisting.usaspending.gov - updated weekly', + id: 'assistance-listing-numbers', + kind: 'upload', + label: 'Upload CFDA / ALN Data', + latestImport: { + attemptedRows: 7, + dataset: 'assistance-listing-numbers', + filename: 'aln.csv', + id: 3, + importedAt: '2026-06-01T12:02:00Z', + rowsImported: 7, + status: 'Succeeded', + }, + number: 4, + ready: true, + source: { completedAt: '2026-06-01T12:02:00Z', importLogId: 3, rows: 7 }, + stale: false, + staleReason: null, + status: 'done', + }, + { + completed: true, + hint: 'AE Redshift - ae_dwh.pgm_master_data', + id: 'pgm-master-data', + kind: 'import', + label: 'Import PGM Master Data', + number: 5, + ready: true, + source: { completedAt: '2026-06-01T12:03:00Z', key: 'pgm', rows: 18 }, + stale: false, + staleReason: null, + status: 'done', + }, + { + completed: false, + hint: 'Review flagged projects below', + id: 'resolve-project-issues', + kind: 'review', + label: 'Resolve Project Issues', + number: 6, + ready: true, + source: null, + stale: false, + staleReason: null, + status: 'active', + }, + { + completed: false, + hint: 'Locks project data, triggers full expense pull', + id: 'finalize-projects', + kind: 'action', + label: 'Finalize Projects', + number: 7, + ready: false, + source: null, + stale: false, + staleReason: null, + status: 'locked', + }, + ], + completedCount: 5, + cycleEnd: '2026-09-30', + cycleStart: '2025-10-01', + fiscalPeriodOptions: [ + { + cycleEnd: '2026-09-30', + cycleStart: '2025-10-01', + fiscalYear: 'FY26', + label: 'FY:26 - Oct 2025 - Sep 2026', + }, + ], + fiscalYear: 'FY26', + totalCount: 7, + workflowRunId: 1, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + describe('AD419 workflow routes', () => { it('redirects the authenticated homepage to the first workflow stage', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + let requestedFy: string | null = null; + server.use( http.get('/api/user/me', () => { return HttpResponse.json(mockUser); }), http.get('/api/imports/recent', () => { return HttpResponse.json([]); + }), + http.get('/api/projectidentification/setup', () => { + return HttpResponse.json(setupResponse); + }), + http.get('/api/projectlist', ({ request }) => { + requestedFy = new URL(request.url).searchParams.get('fy'); + return HttpResponse.json(projectListResponse); }) ); @@ -32,14 +239,157 @@ describe('AD419 workflow routes', () => { expect( await screen.findByRole('heading', { name: 'Load required data' }) ).toBeInTheDocument(); - expect(screen.getByLabelText('Dataset')).toBeInTheDocument(); - expect(screen.getByLabelText('Import file')).toBeInTheDocument(); + expect(screen.getByText('5 of 7 complete')).toBeInTheDocument(); + expect(screen.getByText('Confirm Fiscal Period')).toBeInTheDocument(); + expect(screen.getByText('Upload All Projects List')).toBeInTheDocument(); + expect(screen.getByText('Import PGM Master Data')).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'Project list · 3' }) + ).toBeInTheDocument(); + expect(screen.getByText('Active NIFA')).toBeInTheDocument(); + expect(screen.getByText('Issues to resolve')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /issues\s*2/i })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect( + screen.getByRole('tab', { name: /clean\s*1/i }) + ).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /all\s*3/i })).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'NIFA Project' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Accession' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Award #' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'AE' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'PI' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'ORGR' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'SFN' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('columnheader', { name: 'Status' }) + ).toBeInTheDocument(); + expect(screen.getByText('204 outside CAES')).toBeInTheDocument(); await waitFor(() => { expect(router.state.location.pathname).toBe( '/workflow/project-identification' ); }); + expect(requestedFy).toBe('FY26'); + } finally { + cleanup(); + } + }); + + it('filters the project list tabs and search text', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + server.use( + http.get('/api/user/me', () => { + return HttpResponse.json(mockUser); + }), + http.get('/api/imports/recent', () => { + return HttpResponse.json([]); + }), + http.get('/api/projectidentification/setup', () => { + return HttpResponse.json(setupResponse); + }), + http.get('/api/projectlist', () => { + return HttpResponse.json(projectListResponse); + }) + ); + + const { cleanup } = renderRoute({ + initialPath: '/workflow/project-identification', + }); + + try { + expect(await screen.findByText('Okonkwo, Y.')).toBeInTheDocument(); + expect(screen.queryByText('Larkspur, S.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: /clean\s*1/i })); + expect(await screen.findByText('Larkspur, S.')).toBeInTheDocument(); + expect(screen.queryByText('Okonkwo, Y.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: /all\s*3/i })); + expect(await screen.findByText('Naidoo, T.')).toBeInTheDocument(); + await user.type( + screen.getByPlaceholderText('Search project, accession, PI...'), + '1078258' + ); + + expect(screen.getByText('Naidoo, T.')).toBeInTheDocument(); + expect(screen.queryByText('Okonkwo, Y.')).not.toBeInTheDocument(); + expect(screen.queryByText('Larkspur, S.')).not.toBeInTheDocument(); + } finally { + cleanup(); + } + }); + + it('shows project list errors and retries the query', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-07-07T12:00:00-07:00')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let projectListRequests = 0; + + server.use( + http.get('/api/user/me', () => { + return HttpResponse.json(mockUser); + }), + http.get('/api/imports/recent', () => { + return HttpResponse.json([]); + }), + http.get('/api/projectidentification/setup', () => { + return HttpResponse.json(setupResponse); + }), + http.get('/api/projectlist', () => { + projectListRequests += 1; + + if (projectListRequests === 1) { + return HttpResponse.json( + { message: 'Project list unavailable.' }, + { status: 500 } + ); + } + + return HttpResponse.json(projectListResponse); + }) + ); + + const { cleanup } = renderRoute({ + initialPath: '/workflow/project-identification', + }); + + try { + expect( + await screen.findByRole('heading', { + name: 'Unable to load project list', + }) + ).toBeInTheDocument(); + expect( + screen.queryByText('Loading project list...') + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect( + await screen.findByRole('heading', { name: 'Project list · 3' }) + ).toBeInTheDocument(); + expect(projectListRequests).toBe(2); } finally { cleanup(); } diff --git a/database/data/StoredProcedures/GetProjectList.sql b/database/data/StoredProcedures/GetProjectList.sql index 97e7f2d..b7e62ab 100644 --- a/database/data/StoredProcedures/GetProjectList.sql +++ b/database/data/StoredProcedures/GetProjectList.sql @@ -34,6 +34,8 @@ BEGIN pgm.ProjectId, pgm.ProjectNumber, pgm.SponsorAwardNumber, + pgm.AwardStartDate, + pgm.AwardEndDate, REPLACE(pgm.SponsorAwardNumber, '-', '') AS AwardKey, CASE WHEN aln.ProgramNumber IS NULL THEN NULL @@ -66,6 +68,9 @@ BEGIN a.ProjectNumber AS NifaProjectNumber, ap.AwardNumber AS NifaAwardNumber, ap.Title, + a.ProjectDirector AS ActiveProjectDirector, + ap.ProjectDirector AS AllProjectDirector, + ap.Department AS Orgr, ap.ProjectStartDate, ap.ProjectEndDate, CASE WHEN ap.ProjectNumber IS NULL THEN 0 ELSE 1 END AS InAllProjects, @@ -77,7 +82,9 @@ BEGIN ELSE 'UNKNOWN' -- unrecognized suffix: fail closed below, never silently Clean END AS NifaSfn FROM [data].[ActiveProjects] a - LEFT JOIN [data].[AllProjects] ap ON ap.ProjectNumber = a.ProjectNumber + LEFT JOIN [data].[AllProjects] ap + ON NULLIF(LTRIM(RTRIM(ap.ProjectNumber)), '') = NULLIF(LTRIM(RTRIM(a.ProjectNumber)), '') + AND NULLIF(LTRIM(RTRIM(ap.AccessionNumber)), '') = NULLIF(LTRIM(RTRIM(a.AccessionNumber)), '') WHERE ISNULL(a.ExcludeFromUi, 0) = 0 ), ActiveWithPgm AS @@ -87,6 +94,8 @@ BEGIN ac.NifaProjectNumber, ac.NifaAwardNumber, ac.Title, + COALESCE(NULLIF(ac.ActiveProjectDirector, ''), NULLIF(ac.AllProjectDirector, '')) AS Pi, + ac.Orgr, ac.ProjectStartDate, ac.ProjectEndDate, ac.InAllProjects, @@ -124,12 +133,14 @@ BEGIN -- Not in All Projects SELECT - CAST(AccessionNumber AS NVARCHAR(50)) AS AccessionNumber, - CAST(NifaProjectNumber AS NVARCHAR(20)) AS NifaProjectNumber, - CAST(NifaAwardNumber AS NVARCHAR(100)) AS NifaAwardNumber, - CAST(Title AS NVARCHAR(MAX)) AS Title, - CAST(PgmProjectNumbers AS NVARCHAR(MAX)) AS PgmProjectNumbers, - CAST('Not in All Projects' AS NVARCHAR(30)) AS status + CAST(NifaProjectNumber AS NVARCHAR(20)) AS NifaProject, + CAST(AccessionNumber AS NVARCHAR(50)) AS Accession, + CAST(NifaAwardNumber AS NVARCHAR(100)) AS AwardNumber, + CAST(PgmProjectNumbers AS NVARCHAR(MAX)) AS Ae, + CAST(Pi AS NVARCHAR(200)) AS Pi, + CAST(Orgr AS NVARCHAR(300)) AS Orgr, + CAST(NifaSfn AS NVARCHAR(10)) AS Sfn, + CAST('Not in All Projects' AS NVARCHAR(30)) AS Status FROM ActiveWithPgm WHERE InAllProjects = 0 @@ -137,11 +148,13 @@ BEGIN -- Expired (project dates fall outside the cycle window) SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('Expired' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -154,11 +167,13 @@ BEGIN -- No PGM match SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('No PGM match' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -168,11 +183,13 @@ BEGIN -- SFN mismatch SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('SFN mismatch' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE HasSfnMismatch = 1 @@ -181,11 +198,13 @@ BEGIN -- Clean (fails nothing) SELECT - CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaProjectNumber AS NVARCHAR(20)), + CAST(AccessionNumber AS NVARCHAR(50)), CAST(NifaAwardNumber AS NVARCHAR(100)), - CAST(Title AS NVARCHAR(MAX)), CAST(PgmProjectNumbers AS NVARCHAR(MAX)), + CAST(Pi AS NVARCHAR(200)), + CAST(Orgr AS NVARCHAR(300)), + CAST(NifaSfn AS NVARCHAR(10)), CAST('Clean' AS NVARCHAR(30)) FROM ActiveWithPgm WHERE InAllProjects = 1 @@ -200,19 +219,25 @@ BEGIN -- 204 outside college: a PGM 204 award not tied to any active project SELECT - CAST(NULL AS NVARCHAR(50)) AS AccessionNumber, - CAST(NULL AS NVARCHAR(20)) AS NifaProjectNumber, - CAST(pc.SponsorAwardNumber AS NVARCHAR(100)) AS NifaAwardNumber, - CAST(NULL AS NVARCHAR(MAX)) AS Title, - STRING_AGG(CAST(pc.ProjectNumber AS NVARCHAR(MAX)), ', ') AS PgmProjectNumbers, - CAST('204 outside college' AS NVARCHAR(30)) AS status + CAST(NULL AS NVARCHAR(20)) AS NifaProject, + CAST(NULL AS NVARCHAR(50)) AS Accession, + CAST(pc.SponsorAwardNumber AS NVARCHAR(100)) AS AwardNumber, + STRING_AGG(CAST(pc.ProjectNumber AS NVARCHAR(MAX)), ', ') AS Ae, + CAST(NULL AS NVARCHAR(200)) AS Pi, + CAST(NULL AS NVARCHAR(300)) AS Orgr, + CAST('204' AS NVARCHAR(10)) AS Sfn, + CAST('204 outside college' AS NVARCHAR(30)) AS Status FROM PgmClassified pc WHERE pc.PgmSfnBucket = '204' + AND (pc.AwardEndDate IS NULL OR pc.AwardEndDate >= @cycleStart) + AND (pc.AwardStartDate IS NULL OR pc.AwardStartDate <= @cycleEnd) AND NOT EXISTS ( SELECT 1 FROM [data].[ActiveProjects] a - JOIN [data].[AllProjects] ap ON ap.ProjectNumber = a.ProjectNumber + JOIN [data].[AllProjects] ap + ON NULLIF(LTRIM(RTRIM(ap.ProjectNumber)), '') = NULLIF(LTRIM(RTRIM(a.ProjectNumber)), '') + AND NULLIF(LTRIM(RTRIM(ap.AccessionNumber)), '') = NULLIF(LTRIM(RTRIM(a.AccessionNumber)), '') WHERE ISNULL(a.ExcludeFromUi, 0) = 0 AND ap.AwardNumber IS NOT NULL AND REPLACE(ap.AwardNumber, '-', '') = pc.AwardKey diff --git a/server.core/Data/AppDbContext.cs b/server.core/Data/AppDbContext.cs index 2db0191..4e5dce5 100644 --- a/server.core/Data/AppDbContext.cs +++ b/server.core/Data/AppDbContext.cs @@ -10,6 +10,8 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet ImportLogs => Set(); public DbSet Users => Set(); + public DbSet WorkflowRuns => Set(); + public DbSet WorkflowChecklistItemStates => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -18,5 +20,24 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema(AppSchema); modelBuilder.Entity().ToTable("ImportLog", AppSchema); modelBuilder.Entity().ToTable("Users", AppSchema); + modelBuilder.Entity().ToTable("WorkflowRun", AppSchema); + modelBuilder.Entity().ToTable("WorkflowChecklistItemState", AppSchema); + + modelBuilder.Entity() + .HasIndex(run => run.IsCurrent) + .IsUnique() + .HasFilter("[IsCurrent] = 1"); + + modelBuilder.Entity() + .HasMany(run => run.ChecklistItemStates) + .WithOne(state => state.WorkflowRun) + .HasForeignKey(state => state.WorkflowRunId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(state => state.SourceImportLog) + .WithMany() + .HasForeignKey(state => state.SourceImportLogId) + .OnDelete(DeleteBehavior.SetNull); } } diff --git a/server.core/Domain/WorkflowChecklistItemState.cs b/server.core/Domain/WorkflowChecklistItemState.cs new file mode 100644 index 0000000..13a11d9 --- /dev/null +++ b/server.core/Domain/WorkflowChecklistItemState.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Server.Core.Domain; + +[Index(nameof(WorkflowRunId), nameof(ItemId), IsUnique = true)] +public class WorkflowChecklistItemState +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + public int WorkflowRunId { get; set; } + + public WorkflowRun WorkflowRun { get; set; } = null!; + + [Required] + [MaxLength(80)] + public string ItemId { get; set; } = string.Empty; + + public DateTimeOffset? CompletedAt { get; set; } + + public Guid? CompletedByEntraId { get; set; } + + [MaxLength(200)] + public string? CompletedByName { get; set; } + + [MaxLength(320)] + public string? CompletedByEmail { get; set; } + + public int? SourceImportLogId { get; set; } + + public ImportLog? SourceImportLog { get; set; } + + [MaxLength(160)] + public string? SourceKey { get; set; } + + public int? SourceRows { get; set; } + + public DateTimeOffset? SourceCompletedAt { get; set; } +} diff --git a/server.core/Domain/WorkflowRun.cs b/server.core/Domain/WorkflowRun.cs new file mode 100644 index 0000000..5f68df0 --- /dev/null +++ b/server.core/Domain/WorkflowRun.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Server.Core.Domain; + +public class WorkflowRun +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + [Required] + [MaxLength(16)] + public string FiscalYear { get; set; } = string.Empty; + + public DateOnly CycleStart { get; set; } + + public DateOnly CycleEnd { get; set; } + + public bool IsCurrent { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public Guid? CreatedByEntraId { get; set; } + + [MaxLength(200)] + public string? CreatedByName { get; set; } + + [MaxLength(320)] + public string? CreatedByEmail { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } + + public Guid? UpdatedByEntraId { get; set; } + + [MaxLength(200)] + public string? UpdatedByName { get; set; } + + [MaxLength(320)] + public string? UpdatedByEmail { get; set; } + + public List ChecklistItemStates { get; set; } = []; +} diff --git a/server.core/Import/PgmProjectsImportService.cs b/server.core/Import/PgmProjectsImportService.cs index 15b00f6..036a38d 100644 --- a/server.core/Import/PgmProjectsImportService.cs +++ b/server.core/Import/PgmProjectsImportService.cs @@ -157,8 +157,11 @@ public static string BuildSourceCommandText() => /// single-valued attributes come from the budget period containing the report date (falling /// back to the most recent period); people columns aggregate distinct names across all of the /// project's rows. Rows with a null project_id are excluded (they otherwise collapse into one - /// junk bucket). LISTAGG results are cast to a bounded VARCHAR so MSDASQL binds them inline - /// rather than as LOBs, which EXEC ... AT cannot stream. Redshift also disallows differing + /// junk bucket). LISTAGG results are cast to a narrow VARCHAR(255) so MSDASQL binds them inline + /// rather than as LOBs, which EXEC ... AT cannot stream: the Redshift ODBC driver reports any + /// wider VARCHAR as SQL_LONGVARCHAR (streamed) even when the values are short, which fails with + /// error 7341 "cannot get the current row value". Real values are tiny (observed max ~43 chars, + /// a project has at most a handful of distinct people), so 255 has ample headroom. Redshift also disallows differing /// LISTAGG WITHIN GROUP orderings and a trailing semicolon inside the pass-through. LoadedAt is /// not selected here; the destination column defaults to it. /// @@ -179,12 +182,12 @@ WHERE project_id IS NOT NULL ), people AS ( SELECT project_id, - CAST(LISTAGG(DISTINCT principal_investigator_person_name, '; ') AS VARCHAR(8000)) AS principal_investigator_names, - CAST(LISTAGG(DISTINCT piperson, '; ') AS VARCHAR(8000)) AS pi_persons, - CAST(LISTAGG(DISTINCT award_copi_name, '; ') AS VARCHAR(8000)) AS award_copi_names, - CAST(LISTAGG(DISTINCT project_manager_name, '; ') AS VARCHAR(8000)) AS project_manager_names, - CAST(LISTAGG(DISTINCT grant_administrator, '; ') AS VARCHAR(8000)) AS grant_administrators, - CAST(LISTAGG(DISTINCT contractadmin, '; ') AS VARCHAR(8000)) AS contract_admins + CAST(LISTAGG(DISTINCT principal_investigator_person_name, '; ') AS VARCHAR(255)) AS principal_investigator_names, + CAST(LISTAGG(DISTINCT piperson, '; ') AS VARCHAR(255)) AS pi_persons, + CAST(LISTAGG(DISTINCT award_copi_name, '; ') AS VARCHAR(255)) AS award_copi_names, + CAST(LISTAGG(DISTINCT project_manager_name, '; ') AS VARCHAR(255)) AS project_manager_names, + CAST(LISTAGG(DISTINCT grant_administrator, '; ') AS VARCHAR(255)) AS grant_administrators, + CAST(LISTAGG(DISTINCT contractadmin, '; ') AS VARCHAR(255)) AS contract_admins FROM ae_dwh.pgm_master_data WHERE project_id IS NOT NULL GROUP BY project_id diff --git a/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.Designer.cs b/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.Designer.cs new file mode 100644 index 0000000..55387c8 --- /dev/null +++ b/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.Designer.cs @@ -0,0 +1,252 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Server.Core.Data; + +#nullable disable + +namespace server.core.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260710214615_ProjectIdentificationWorkflowState")] + partial class ProjectIdentificationWorkflowState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("app") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Server.Core.Domain.ImportLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttemptedRows") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Dataset") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("RowsImported") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UploadedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("UploadedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("UploadedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Dataset", "CompletedAt", "Id") + .IsDescending(false, true, true); + + b.ToTable("ImportLog", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("EntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("EntraId") + .IsUnique(); + + b.ToTable("Users", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowChecklistItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CompletedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CompletedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("SourceCompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceImportLogId") + .HasColumnType("int"); + + b.Property("SourceKey") + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("SourceRows") + .HasColumnType("int"); + + b.Property("WorkflowRunId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SourceImportLogId"); + + b.HasIndex("WorkflowRunId", "ItemId") + .IsUnique(); + + b.ToTable("WorkflowChecklistItemState", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CycleEnd") + .HasColumnType("date"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("FiscalYear") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("IsCurrent") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("UpdatedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("IsCurrent") + .IsUnique() + .HasFilter("[IsCurrent] = 1"); + + b.ToTable("WorkflowRun", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowChecklistItemState", b => + { + b.HasOne("Server.Core.Domain.ImportLog", "SourceImportLog") + .WithMany() + .HasForeignKey("SourceImportLogId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Server.Core.Domain.WorkflowRun", "WorkflowRun") + .WithMany("ChecklistItemStates") + .HasForeignKey("WorkflowRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceImportLog"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowRun", b => + { + b.Navigation("ChecklistItemStates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.cs b/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.cs new file mode 100644 index 0000000..1db4618 --- /dev/null +++ b/server.core/Migrations/20260710214615_ProjectIdentificationWorkflowState.cs @@ -0,0 +1,110 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace server.core.Migrations +{ + /// + public partial class ProjectIdentificationWorkflowState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "WorkflowRun", + schema: "app", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FiscalYear = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + CycleStart = table.Column(type: "date", nullable: false), + CycleEnd = table.Column(type: "date", nullable: false), + IsCurrent = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedByEntraId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedByName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + CreatedByEmail = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedByEntraId = table.Column(type: "uniqueidentifier", nullable: true), + UpdatedByName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + UpdatedByEmail = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkflowRun", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WorkflowChecklistItemState", + schema: "app", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + WorkflowRunId = table.Column(type: "int", nullable: false), + ItemId = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + CompletedAt = table.Column(type: "datetimeoffset", nullable: true), + CompletedByEntraId = table.Column(type: "uniqueidentifier", nullable: true), + CompletedByName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: true), + CompletedByEmail = table.Column(type: "nvarchar(320)", maxLength: 320, nullable: true), + SourceImportLogId = table.Column(type: "int", nullable: true), + SourceKey = table.Column(type: "nvarchar(160)", maxLength: 160, nullable: true), + SourceRows = table.Column(type: "int", nullable: true), + SourceCompletedAt = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkflowChecklistItemState", x => x.Id); + table.ForeignKey( + name: "FK_WorkflowChecklistItemState_ImportLog_SourceImportLogId", + column: x => x.SourceImportLogId, + principalSchema: "app", + principalTable: "ImportLog", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_WorkflowChecklistItemState_WorkflowRun_WorkflowRunId", + column: x => x.WorkflowRunId, + principalSchema: "app", + principalTable: "WorkflowRun", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowChecklistItemState_SourceImportLogId", + schema: "app", + table: "WorkflowChecklistItemState", + column: "SourceImportLogId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowChecklistItemState_WorkflowRunId_ItemId", + schema: "app", + table: "WorkflowChecklistItemState", + columns: new[] { "WorkflowRunId", "ItemId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowRun_IsCurrent", + schema: "app", + table: "WorkflowRun", + column: "IsCurrent", + unique: true, + filter: "[IsCurrent] = 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkflowChecklistItemState", + schema: "app"); + + migrationBuilder.DropTable( + name: "WorkflowRun", + schema: "app"); + } + } +} diff --git a/server.core/Migrations/AppDbContextModelSnapshot.cs b/server.core/Migrations/AppDbContextModelSnapshot.cs index 9b17a77..4cf3b2f 100644 --- a/server.core/Migrations/AppDbContextModelSnapshot.cs +++ b/server.core/Migrations/AppDbContextModelSnapshot.cs @@ -108,6 +108,141 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Users", "app"); }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowChecklistItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CompletedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CompletedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ItemId") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("SourceCompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceImportLogId") + .HasColumnType("int"); + + b.Property("SourceKey") + .HasMaxLength(160) + .HasColumnType("nvarchar(160)"); + + b.Property("SourceRows") + .HasColumnType("int"); + + b.Property("WorkflowRunId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SourceImportLogId"); + + b.HasIndex("WorkflowRunId", "ItemId") + .IsUnique(); + + b.ToTable("WorkflowChecklistItemState", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("CreatedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CycleEnd") + .HasColumnType("date"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("FiscalYear") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("IsCurrent") + .HasColumnType("bit"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedByEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("UpdatedByEntraId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedByName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("IsCurrent") + .IsUnique() + .HasFilter("[IsCurrent] = 1"); + + b.ToTable("WorkflowRun", "app"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowChecklistItemState", b => + { + b.HasOne("Server.Core.Domain.ImportLog", "SourceImportLog") + .WithMany() + .HasForeignKey("SourceImportLogId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Server.Core.Domain.WorkflowRun", "WorkflowRun") + .WithMany("ChecklistItemStates") + .HasForeignKey("WorkflowRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SourceImportLog"); + + b.Navigation("WorkflowRun"); + }); + + modelBuilder.Entity("Server.Core.Domain.WorkflowRun", b => + { + b.Navigation("ChecklistItemStates"); + }); #pragma warning restore 612, 618 } } diff --git a/server.core/updateDatabase.sh b/server.core/updateDatabase.sh index 10b76e5..f366d8e 100755 --- a/server.core/updateDatabase.sh +++ b/server.core/updateDatabase.sh @@ -8,10 +8,10 @@ ROOT_DIR="$(dirname "$0")/.." if [ -z "$1" ]; then echo "Updating database to latest migration..." - dotnet ef database update -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" + dotnet ef database update --context AppDbContext -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" else echo "Updating database to migration '$1'..." - dotnet ef database update "$1" -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" + dotnet ef database update "$1" --context AppDbContext -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" fi echo "Database updated successfully." diff --git a/server/Controllers/PgmProjectsController.cs b/server/Controllers/PgmProjectsController.cs index bc31ab5..29d8cf8 100644 --- a/server/Controllers/PgmProjectsController.cs +++ b/server/Controllers/PgmProjectsController.cs @@ -1,15 +1,23 @@ using Microsoft.AspNetCore.Mvc; using Server.Core.Import; +using Server.ProjectIdentification; namespace Server.Controllers; public class PgmProjectsController : ApiControllerBase { private readonly IPgmProjectsImportService _importService; + private readonly IProjectIdentificationService _projectIdentificationService; + private readonly ILogger _logger; - public PgmProjectsController(IPgmProjectsImportService importService) + public PgmProjectsController( + IPgmProjectsImportService importService, + IProjectIdentificationService projectIdentificationService, + ILogger logger) { _importService = importService; + _projectIdentificationService = projectIdentificationService; + _logger = logger; } // POST api/pgmprojects/import?reportDate=2026-06-30 @@ -25,6 +33,22 @@ public async Task> Import( } var result = await _importService.ImportAsync(date, cancellationToken); + try + { + await _projectIdentificationService.RecordPgmImportAsync(result, User, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "PGM projects import succeeded for report date {ReportDate}, but workflow recording failed.", + date); + } + return Ok(result); } } diff --git a/server/Controllers/ProjectIdentificationController.cs b/server/Controllers/ProjectIdentificationController.cs new file mode 100644 index 0000000..b3edfb4 --- /dev/null +++ b/server/Controllers/ProjectIdentificationController.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc; +using Server.Models.ProjectIdentification; +using Server.ProjectIdentification; + +namespace Server.Controllers; + +public sealed class ProjectIdentificationController( + IProjectIdentificationService projectIdentificationService) : ApiControllerBase +{ + [HttpGet("setup")] + public async Task> Setup( + CancellationToken cancellationToken) + { + var response = await projectIdentificationService.GetSetupAsync(User, cancellationToken); + return Ok(response); + } + + [HttpPut("fiscal-period")] + public async Task> ConfirmFiscalPeriod( + FiscalPeriodRequest request, + CancellationToken cancellationToken) + { + var response = await projectIdentificationService.ConfirmFiscalPeriodAsync( + request.FiscalYear, + User, + cancellationToken); + + if (response is null) + { + return BadRequest("A fiscal year value like FY26 is required."); + } + + return Ok(response); + } + + [HttpPut("checklist/{itemId}")] + public async Task> SetChecklistItemCompletion( + [FromRoute] string itemId, + ChecklistCompletionRequest request, + CancellationToken cancellationToken) + { + var response = await projectIdentificationService.SetChecklistItemCompletionAsync( + itemId, + request.Completed, + User, + cancellationToken); + + if (response is null) + { + return BadRequest("The checklist item cannot be updated in its current state."); + } + + return Ok(response); + } +} diff --git a/server/Controllers/ProjectListController.cs b/server/Controllers/ProjectListController.cs new file mode 100644 index 0000000..7f30826 --- /dev/null +++ b/server/Controllers/ProjectListController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using Server.Models; +using Server.ProjectList; + +namespace Server.Controllers; + +public sealed class ProjectListController(IProjectListService projectListService) : ApiControllerBase +{ + [HttpGet] + public async Task Get([FromQuery] string? fy, CancellationToken cancellationToken) + { + if (!FiscalYearCycle.TryParse(fy, out var cycle)) + { + return BadRequest("A fiscal year query value like FY26 is required."); + } + + var response = await projectListService.GetAsync(cycle, cancellationToken); + return Ok(response); + } +} diff --git a/server/Models/FiscalYearCycle.cs b/server/Models/FiscalYearCycle.cs new file mode 100644 index 0000000..a08f34b --- /dev/null +++ b/server/Models/FiscalYearCycle.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Server.Models; + +public sealed record FiscalYearCycle( + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd) +{ + public static bool TryParse(string? fiscalYear, [NotNullWhen(true)] out FiscalYearCycle? cycle) + { + cycle = null; + + if (string.IsNullOrWhiteSpace(fiscalYear)) + { + return false; + } + + var normalized = fiscalYear.Trim().ToUpperInvariant(); + var yearText = normalized.StartsWith("FY", StringComparison.Ordinal) + ? normalized[2..] + : string.Empty; + + if (yearText.Length is not (2 or 4) || + !int.TryParse(yearText, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedYear)) + { + return false; + } + + var endYear = yearText.Length == 2 ? 2000 + parsedYear : parsedYear; + if (endYear is < 2 or > 9999) + { + return false; + } + + var startYear = endYear - 1; + cycle = new FiscalYearCycle( + $"FY{endYear % 100:00}", + new DateOnly(startYear, 10, 1), + new DateOnly(endYear, 9, 30)); + return true; + } +} diff --git a/server/Models/ProjectIdentification/ProjectIdentificationDtos.cs b/server/Models/ProjectIdentification/ProjectIdentificationDtos.cs new file mode 100644 index 0000000..a3dd3f0 --- /dev/null +++ b/server/Models/ProjectIdentification/ProjectIdentificationDtos.cs @@ -0,0 +1,43 @@ +using Server.Models.Imports; + +namespace Server.Models.ProjectIdentification; + +public sealed record ProjectIdentificationSetupResponse( + int WorkflowRunId, + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd, + IReadOnlyList FiscalPeriodOptions, + int CompletedCount, + int TotalCount, + IReadOnlyList ChecklistItems); + +public sealed record FiscalPeriodOptionDto( + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd, + string Label); + +public sealed record ProjectChecklistItemDto( + string Id, + int Number, + string Label, + string Hint, + string Kind, + string Status, + bool Completed, + bool Ready, + bool Stale, + string? StaleReason, + RecentImportResponse? LatestImport, + ProjectChecklistSourceDto? Source); + +public sealed record ProjectChecklistSourceDto( + int? ImportLogId, + string? Key, + int? Rows, + DateTimeOffset? CompletedAt); + +public sealed record FiscalPeriodRequest(string FiscalYear); + +public sealed record ChecklistCompletionRequest(bool Completed); diff --git a/server/Models/ProjectList/ProjectListDtos.cs b/server/Models/ProjectList/ProjectListDtos.cs new file mode 100644 index 0000000..7c75057 --- /dev/null +++ b/server/Models/ProjectList/ProjectListDtos.cs @@ -0,0 +1,36 @@ +namespace Server.Models.ProjectList; + +public sealed record ProjectListResponse( + string FiscalYear, + DateOnly CycleStart, + DateOnly CycleEnd, + ProjectListCountsDto Counts, + ProjectListSummaryDto Summary, + IReadOnlyList Rows); + +public sealed record ProjectListCountsDto( + int Issues, + int Clean, + int All); + +public sealed record ProjectListSummaryDto( + int ActiveNifa, + int AllNifa, + int PgmRecords, + int AlnCodes, + int IssuesToResolve, + IReadOnlyList SfnDistribution); + +public sealed record SfnDistributionDto( + string Sfn, + int Count); + +public sealed record ProjectListRowDto( + string? NifaProject, + string? Accession, + string? AwardNumber, + string? Ae, + string? Pi, + string? Orgr, + string? Sfn, + string Status); diff --git a/server/Program.cs b/server/Program.cs index dda14fe..373f77e 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -10,6 +10,8 @@ using Server.Core.Import; using Server.Core.Notification; using Server.Helpers; +using Server.ProjectIdentification; +using Server.ProjectList; var builder = WebApplication.CreateBuilder(args); @@ -55,6 +57,8 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // add auth policies here // add db context (check secrets first, then config, then default) diff --git a/server/ProjectIdentification/IProjectIdentificationService.cs b/server/ProjectIdentification/IProjectIdentificationService.cs new file mode 100644 index 0000000..d02d5ae --- /dev/null +++ b/server/ProjectIdentification/IProjectIdentificationService.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using Server.Core.Import; +using Server.Models.ProjectIdentification; + +namespace Server.ProjectIdentification; + +public interface IProjectIdentificationService +{ + Task GetSetupAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task ConfirmFiscalPeriodAsync( + string? fiscalYear, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task SetChecklistItemCompletionAsync( + string itemId, + bool completed, + ClaimsPrincipal user, + CancellationToken cancellationToken); + + Task RecordPgmImportAsync( + PgmProjectsImportResult result, + ClaimsPrincipal user, + CancellationToken cancellationToken); +} diff --git a/server/ProjectIdentification/ProjectIdentificationService.cs b/server/ProjectIdentification/ProjectIdentificationService.cs new file mode 100644 index 0000000..c839eb9 --- /dev/null +++ b/server/ProjectIdentification/ProjectIdentificationService.cs @@ -0,0 +1,664 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Server.Authorization; +using Server.Core.Data; +using Server.Core.Domain; +using Server.Core.Import; +using Server.Import; +using Server.Models; +using Server.Models.Imports; +using Server.Models.ProjectIdentification; +using Server.ProjectList; + +namespace Server.ProjectIdentification; + +public sealed class ProjectIdentificationService( + AppDbContext dbContext, + IFlatFileImportRegistry importRegistry, + IProjectListService projectListService) : IProjectIdentificationService +{ + private const string FiscalPeriodItemId = "fiscal-period"; + private const string PgmItemId = "pgm-master-data"; + private const string ResolveIssuesItemId = "resolve-project-issues"; + private const string FinalizeProjectsItemId = "finalize-projects"; + + private static readonly ChecklistItemDefinition[] ChecklistItems = + [ + new(FiscalPeriodItemId, 1, "Confirm Fiscal Period", "Set the reporting cycle", "select"), + new("all-projects", 2, "Upload All Projects List", "ANR - all NIFA projects across UC campuses", "upload"), + new("active-projects", 3, "Upload Active Project List", "ANR - CAES projects required to report", "upload"), + new("assistance-listing-numbers", 4, "Upload CFDA / ALN Data", "assistancelisting.usaspending.gov - updated weekly", "upload"), + new(PgmItemId, 5, "Import PGM Master Data", "AE Redshift - ae_dwh.pgm_master_data", "import"), + new(ResolveIssuesItemId, 6, "Resolve Project Issues", "Review flagged projects below", "review"), + new(FinalizeProjectsItemId, 7, "Finalize Projects", "Locks project data, triggers full expense pull", "action"), + ]; + + private static readonly Dictionary DatasetByItemId = new(StringComparer.OrdinalIgnoreCase) + { + ["all-projects"] = "all-projects", + ["active-projects"] = "active-projects", + ["assistance-listing-numbers"] = "assistance-listing-numbers", + }; + + public async Task GetSetupAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var run = await GetOrCreateCurrentRunAsync(user, cancellationToken); + var latestImports = await GetLatestImportsAsync(cancellationToken); + + return CreateSetupResponse(run, latestImports); + } + + public async Task ConfirmFiscalPeriodAsync( + string? fiscalYear, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + if (!FiscalYearCycle.TryParse(fiscalYear, out var cycle)) + { + return null; + } + + var run = await GetOrCreateCurrentRunAsync(user, cancellationToken); + var now = DateTimeOffset.UtcNow; + var changed = !string.Equals(run.FiscalYear, cycle.FiscalYear, StringComparison.OrdinalIgnoreCase); + + if (changed) + { + run.FiscalYear = cycle.FiscalYear; + run.CycleStart = cycle.CycleStart; + run.CycleEnd = cycle.CycleEnd; + run.ChecklistItemStates.Clear(); + } + + Touch(run, user, now); + var fiscalState = GetOrCreateState(run, FiscalPeriodItemId); + CompleteState(fiscalState, user, now); + + await dbContext.SaveChangesAsync(cancellationToken); + + var latestImports = await GetLatestImportsAsync(cancellationToken); + return CreateSetupResponse(run, latestImports); + } + + public async Task SetChecklistItemCompletionAsync( + string itemId, + bool completed, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var definition = FindDefinition(itemId); + if (definition is null || definition.Id == FinalizeProjectsItemId) + { + return null; + } + + var run = await GetOrCreateCurrentRunAsync(user, cancellationToken); + var latestImports = await GetLatestImportsAsync(cancellationToken); + var items = CreateChecklistItems(run, latestImports); + var requestedItem = items.Single(item => item.Id == definition.Id); + + if (!completed) + { + ClearFrom(run, definition.Number); + Touch(run, user, DateTimeOffset.UtcNow); + await dbContext.SaveChangesAsync(cancellationToken); + + return CreateSetupResponse(run, latestImports); + } + + var previousComplete = items + .Where(item => item.Number < definition.Number) + .All(item => item.Completed); + + if (!previousComplete || !requestedItem.Ready) + { + return null; + } + + if (definition.Id == ResolveIssuesItemId + && !await ProjectIssuesResolvedAsync(run, cancellationToken)) + { + return null; + } + + var now = DateTimeOffset.UtcNow; + var state = GetOrCreateState(run, definition.Id); + CompleteState(state, user, now); + + if (DatasetByItemId.TryGetValue(definition.Id, out var datasetId)) + { + var latestImport = latestImports.GetValueOrDefault(datasetId); + if (latestImport is null || !IsSucceeded(latestImport)) + { + return null; + } + + state.SourceImportLogId = latestImport.Id; + state.SourceRows = latestImport.RowsImported; + state.SourceCompletedAt = latestImport.ImportedAt; + state.SourceKey = null; + } + else if (definition.Id == PgmItemId) + { + if (state.SourceRows is null || state.SourceCompletedAt is null) + { + return null; + } + } + else + { + state.SourceImportLogId = null; + state.SourceRows = null; + state.SourceCompletedAt = null; + state.SourceKey = null; + } + + Touch(run, user, now); + await dbContext.SaveChangesAsync(cancellationToken); + + latestImports = await GetLatestImportsAsync(cancellationToken); + return CreateSetupResponse(run, latestImports); + } + + private async Task ProjectIssuesResolvedAsync( + WorkflowRun run, + CancellationToken cancellationToken) + { + if (!FiscalYearCycle.TryParse(run.FiscalYear, out var cycle)) + { + return false; + } + + var projectList = await projectListService.GetAsync(cycle, cancellationToken); + return projectList.Summary.IssuesToResolve == 0; + } + + public async Task RecordPgmImportAsync( + PgmProjectsImportResult result, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var run = await GetOrCreateCurrentRunAsync(user, cancellationToken); + var now = DateTimeOffset.UtcNow; + var state = GetOrCreateState(run, PgmItemId); + + state.CompletedAt = null; + state.CompletedByEntraId = null; + state.CompletedByName = null; + state.CompletedByEmail = null; + state.SourceImportLogId = null; + state.SourceKey = $"pgm:{result.ReportDate:yyyy-MM-dd}:{now:O}"; + state.SourceRows = result.RowsImported; + state.SourceCompletedAt = now; + + ClearAfter(run, ChecklistItems.Single(item => item.Id == PgmItemId).Number); + Touch(run, user, now); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private async Task GetOrCreateCurrentRunAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + var run = await GetCurrentRunAsync(cancellationToken); + + if (run is not null) + { + return run; + } + + var cycle = CurrentFiscalYearCycle(); + var now = DateTimeOffset.UtcNow; + run = new WorkflowRun + { + FiscalYear = cycle.FiscalYear, + CycleStart = cycle.CycleStart, + CycleEnd = cycle.CycleEnd, + CreatedAt = now, + IsCurrent = true, + UpdatedAt = now, + }; + + SetCreatedBy(run, user); + SetUpdatedBy(run, user); + dbContext.WorkflowRuns.Add(run); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + return run; + } + catch (DbUpdateException ex) when (IsCurrentRunUniquenessViolation(ex)) + { + dbContext.Entry(run).State = EntityState.Detached; + return await GetCurrentRunAsync(cancellationToken) + ?? throw new InvalidOperationException("A current workflow run already exists but could not be loaded.", ex); + } + } + + private Task GetCurrentRunAsync(CancellationToken cancellationToken) => + dbContext.WorkflowRuns + .Include(item => item.ChecklistItemStates) + .Where(item => item.IsCurrent) + .OrderByDescending(item => item.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + private ProjectIdentificationSetupResponse CreateSetupResponse( + WorkflowRun run, + IReadOnlyDictionary latestImports) + { + var items = CreateChecklistItems(run, latestImports); + var completedCount = items.Count(item => item.Completed); + + return new ProjectIdentificationSetupResponse( + run.Id, + run.FiscalYear, + run.CycleStart, + run.CycleEnd, + CreateFiscalPeriodOptions(run.FiscalYear), + completedCount, + items.Count, + items); + } + + private static IReadOnlyList CreateFiscalPeriodOptions(string selectedFiscalYear) + { + var current = CurrentFiscalYearCycle(); + var currentYear = ParseFiscalYearNumber(current.FiscalYear) + ?? throw new InvalidOperationException($"Current fiscal year '{current.FiscalYear}' could not be parsed."); + var selectedYear = ParseFiscalYearNumber(selectedFiscalYear) ?? currentYear; + var years = new[] { currentYear - 1, currentYear, currentYear + 1, selectedYear } + .Distinct() + .Order() + .ToList(); + + return years + .Select(year => + { + var fiscalYear = $"FY{year % 100:00}"; + if (!FiscalYearCycle.TryParse(fiscalYear, out var cycle)) + { + throw new InvalidOperationException($"Fiscal year option '{fiscalYear}' could not be parsed."); + } + + return new FiscalPeriodOptionDto( + cycle.FiscalYear, + cycle.CycleStart, + cycle.CycleEnd, + $"{cycle.FiscalYear.Replace("FY", "FY:")} - {FormatPeriod(cycle)}"); + }) + .ToList(); + } + + private List CreateChecklistItems( + WorkflowRun run, + IReadOnlyDictionary latestImports) + { + var states = run.ChecklistItemStates + .ToDictionary(state => state.ItemId, StringComparer.OrdinalIgnoreCase); + var completedByItemId = new Dictionary(StringComparer.OrdinalIgnoreCase); + var response = new List(); + + foreach (var definition in ChecklistItems) + { + states.TryGetValue(definition.Id, out var state); + var previousComplete = ChecklistItems + .Where(item => item.Number < definition.Number) + .All(item => completedByItemId.GetValueOrDefault(item.Id)); + var ready = IsReady(definition, state, latestImports); + var completed = IsEffectivelyComplete(definition, state, latestImports); + var stale = IsStale(definition, state, latestImports, out var staleReason); + var status = GetStatus(definition, previousComplete, ready, completed, stale); + var latestImport = DatasetByItemId.TryGetValue(definition.Id, out var datasetId) + ? latestImports.GetValueOrDefault(datasetId) + : null; + + completedByItemId[definition.Id] = completed; + response.Add(new ProjectChecklistItemDto( + definition.Id, + definition.Number, + definition.Label, + definition.Hint, + definition.Kind, + status, + completed, + ready, + stale, + staleReason, + latestImport, + state is null + ? null + : new ProjectChecklistSourceDto( + state.SourceImportLogId, + state.SourceKey, + state.SourceRows, + state.SourceCompletedAt))); + } + + return response; + } + + private static string GetStatus( + ChecklistItemDefinition definition, + bool previousComplete, + bool ready, + bool completed, + bool stale) + { + if (completed) + { + return "done"; + } + + if (stale) + { + return "stale"; + } + + if (definition.Id == FinalizeProjectsItemId || !previousComplete) + { + return "locked"; + } + + if (!ready || definition.Id == FiscalPeriodItemId) + { + return "active"; + } + + return "ready"; + } + + private static bool IsReady( + ChecklistItemDefinition definition, + WorkflowChecklistItemState? state, + IReadOnlyDictionary latestImports) + { + if (definition.Id == FiscalPeriodItemId || definition.Id == ResolveIssuesItemId) + { + return true; + } + + if (definition.Id == FinalizeProjectsItemId) + { + return false; + } + + if (DatasetByItemId.TryGetValue(definition.Id, out var datasetId)) + { + var latestImport = latestImports.GetValueOrDefault(datasetId); + return latestImport is not null && IsSucceeded(latestImport); + } + + return definition.Id == PgmItemId && state?.SourceRows is not null && state.SourceCompletedAt is not null; + } + + private static bool IsEffectivelyComplete( + ChecklistItemDefinition definition, + WorkflowChecklistItemState? state, + IReadOnlyDictionary latestImports) + { + if (state?.CompletedAt is null || definition.Id == FinalizeProjectsItemId) + { + return false; + } + + if (DatasetByItemId.TryGetValue(definition.Id, out var datasetId)) + { + var latestImport = latestImports.GetValueOrDefault(datasetId); + return latestImport is not null + && IsSucceeded(latestImport) + && state.SourceImportLogId == latestImport.Id; + } + + if (definition.Id == PgmItemId) + { + return state.SourceRows is not null && state.SourceCompletedAt is not null; + } + + return true; + } + + private static bool IsStale( + ChecklistItemDefinition definition, + WorkflowChecklistItemState? state, + IReadOnlyDictionary latestImports, + out string? reason) + { + reason = null; + + if (state?.CompletedAt is null || !DatasetByItemId.TryGetValue(definition.Id, out var datasetId)) + { + return false; + } + + var latestImport = latestImports.GetValueOrDefault(datasetId); + if (latestImport is null) + { + reason = "The import used to complete this item is no longer available."; + return true; + } + + if (!IsSucceeded(latestImport)) + { + reason = "The latest import attempt did not succeed."; + return true; + } + + if (state.SourceImportLogId != latestImport.Id) + { + reason = "A newer import has been uploaded since this item was marked done."; + return true; + } + + return false; + } + + private async Task> GetLatestImportsAsync( + CancellationToken cancellationToken) + { + var datasetIds = importRegistry.Datasets.Select(dataset => dataset.Id).ToList(); + var recentLogs = new List(); + foreach (var datasetId in datasetIds) + { + var recentLog = await dbContext.ImportLogs + .AsNoTracking() + .Where(log => log.Dataset == datasetId) + .OrderByDescending(log => log.CompletedAt) + .ThenByDescending(log => log.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (recentLog is not null) + { + recentLogs.Add(recentLog); + } + } + + return recentLogs + .ToDictionary( + log => log.Dataset, + log => new RecentImportResponse( + log.Id, + log.Dataset, + log.Filename, + log.Status, + log.AttemptedRows, + log.RowsImported, + log.CompletedAt, + log.UploadedByName, + log.UploadedByEmail, + DeserializeValidationStats(log.ErrorPayload)), + StringComparer.OrdinalIgnoreCase); + } + + private static ImportValidationStatsResponse? DeserializeValidationStats(string? errorPayload) + { + if (string.IsNullOrWhiteSpace(errorPayload)) + { + return null; + } + + try + { + var payload = JsonSerializer.Deserialize(errorPayload); + if (payload is null) + { + return null; + } + + return new ImportValidationStatsResponse( + payload.RowCount, + payload.RowsWithErrors, + payload.ErrorCount, + payload.FileErrors.Count); + } + catch (JsonException) + { + return null; + } + } + + private static WorkflowChecklistItemState GetOrCreateState(WorkflowRun run, string itemId) + { + var state = run.ChecklistItemStates.SingleOrDefault(item => + string.Equals(item.ItemId, itemId, StringComparison.OrdinalIgnoreCase)); + + if (state is not null) + { + return state; + } + + state = new WorkflowChecklistItemState + { + ItemId = itemId, + WorkflowRun = run, + WorkflowRunId = run.Id, + }; + run.ChecklistItemStates.Add(state); + return state; + } + + private static void CompleteState( + WorkflowChecklistItemState state, + ClaimsPrincipal user, + DateTimeOffset completedAt) + { + state.CompletedAt = completedAt; + state.CompletedByEntraId = user.GetEntraId(); + state.CompletedByName = Truncate(user.FindFirst("name")?.Value ?? user.Identity?.Name, 200); + state.CompletedByEmail = Truncate( + user.FindFirst("preferred_username")?.Value ?? user.FindFirst(ClaimTypes.Email)?.Value, + 320); + } + + private static void ClearFrom(WorkflowRun run, int number) + { + var itemIds = ChecklistItems + .Where(item => item.Number >= number) + .Select(item => item.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + run.ChecklistItemStates.RemoveAll(state => itemIds.Contains(state.ItemId)); + } + + private static void ClearAfter(WorkflowRun run, int number) + { + var itemIds = ChecklistItems + .Where(item => item.Number > number) + .Select(item => item.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + run.ChecklistItemStates.RemoveAll(state => itemIds.Contains(state.ItemId)); + } + + private static ChecklistItemDefinition? FindDefinition(string itemId) => + ChecklistItems.SingleOrDefault(item => + string.Equals(item.Id, itemId, StringComparison.OrdinalIgnoreCase)); + + private static bool IsSucceeded(RecentImportResponse import) => + string.Equals(import.Status, "Succeeded", StringComparison.OrdinalIgnoreCase); + + private static FiscalYearCycle CurrentFiscalYearCycle() + { + var now = DateTimeOffset.Now; + var calendarYear = now.Year; + var fiscalYear = now.Month >= 10 ? calendarYear + 1 : calendarYear; + var fiscalYearText = $"FY{fiscalYear % 100:00}"; + if (!FiscalYearCycle.TryParse(fiscalYearText, out var cycle)) + { + throw new InvalidOperationException($"Current fiscal year '{fiscalYearText}' could not be parsed."); + } + + return cycle; + } + + private static bool IsCurrentRunUniquenessViolation(DbUpdateException exception) => + exception.GetBaseException() is SqlException sqlException + && sqlException.Errors.Cast().Any(error => error.Number is 2601 or 2627); + + private static int? ParseFiscalYearNumber(string fiscalYear) + { + var normalized = fiscalYear.Replace("FY:", "FY", StringComparison.OrdinalIgnoreCase); + if (normalized.StartsWith("FY", StringComparison.OrdinalIgnoreCase) + && int.TryParse(normalized[2..], out var year)) + { + return 2000 + year; + } + + return null; + } + + private static string FormatPeriod(FiscalYearCycle cycle) => + $"{cycle.CycleStart:MMM yyyy} - {cycle.CycleEnd:MMM yyyy}"; + + private static void Touch(WorkflowRun run, ClaimsPrincipal user, DateTimeOffset updatedAt) + { + run.UpdatedAt = updatedAt; + SetUpdatedBy(run, user); + } + + private static void SetCreatedBy(WorkflowRun run, ClaimsPrincipal user) + { + run.CreatedByEntraId = user.GetEntraId(); + run.CreatedByName = Truncate(user.FindFirst("name")?.Value ?? user.Identity?.Name, 200); + run.CreatedByEmail = Truncate( + user.FindFirst("preferred_username")?.Value ?? user.FindFirst(ClaimTypes.Email)?.Value, + 320); + } + + private static void SetUpdatedBy(WorkflowRun run, ClaimsPrincipal user) + { + run.UpdatedByEntraId = user.GetEntraId(); + run.UpdatedByName = Truncate(user.FindFirst("name")?.Value ?? user.Identity?.Name, 200); + run.UpdatedByEmail = Truncate( + user.FindFirst("preferred_username")?.Value ?? user.FindFirst(ClaimTypes.Email)?.Value, + 320); + } + + private static string? Truncate(string? value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } + + private sealed record ChecklistItemDefinition( + string Id, + int Number, + string Label, + string Hint, + string Kind); + + private sealed class ImportValidationHistoryReadModel + { + public int RowCount { get; set; } + public int RowsWithErrors { get; set; } + public int ErrorCount { get; set; } + public List FileErrors { get; set; } = []; + } +} diff --git a/server/ProjectList/IProjectListService.cs b/server/ProjectList/IProjectListService.cs new file mode 100644 index 0000000..2387826 --- /dev/null +++ b/server/ProjectList/IProjectListService.cs @@ -0,0 +1,9 @@ +using Server.Models.ProjectList; +using Server.Models; + +namespace Server.ProjectList; + +public interface IProjectListService +{ + Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken); +} diff --git a/server/ProjectList/ProjectListResponseFactory.cs b/server/ProjectList/ProjectListResponseFactory.cs new file mode 100644 index 0000000..566814f --- /dev/null +++ b/server/ProjectList/ProjectListResponseFactory.cs @@ -0,0 +1,41 @@ +using Server.Models.ProjectList; +using Server.Models; + +namespace Server.ProjectList; + +public static class ProjectListResponseFactory +{ + private const string CleanStatus = "Clean"; + + public static ProjectListResponse Create( + FiscalYearCycle cycle, + IReadOnlyList rows, + int activeNifa, + int allNifa, + int pgmRecords, + int alnCodes) + { + var issues = rows.Count(row => row.Status != CleanStatus); + var clean = rows.Count(row => row.Status == CleanStatus); + var sfnDistribution = rows + .Where(row => !string.IsNullOrWhiteSpace(row.Sfn)) + .GroupBy(row => row.Sfn!) + .OrderBy(group => group.Key) + .Select(group => new SfnDistributionDto(group.Key, group.Count())) + .ToList(); + + return new ProjectListResponse( + cycle.FiscalYear, + cycle.CycleStart, + cycle.CycleEnd, + new ProjectListCountsDto(issues, clean, rows.Count), + new ProjectListSummaryDto( + activeNifa, + allNifa, + pgmRecords, + alnCodes, + issues, + sfnDistribution), + rows); + } +} diff --git a/server/ProjectList/ProjectListService.cs b/server/ProjectList/ProjectListService.cs new file mode 100644 index 0000000..5c7dd75 --- /dev/null +++ b/server/ProjectList/ProjectListService.cs @@ -0,0 +1,83 @@ +using System.Data; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Server.Core.Data; +using Server.Models; +using Server.Models.ProjectList; + +namespace Server.ProjectList; + +public sealed class ProjectListService( + DataDbContext dataDbContext, + IConfiguration configuration) : IProjectListService +{ + public async Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken) + { + var connectionString = DataDbConnection.Resolve( + configuration, + dataDbContext.Database.GetConnectionString()); + + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + var rows = (await connection.QueryAsync(new CommandDefinition( + "[data].[GetProjectList]", + new + { + cycleStart = cycle.CycleStart.ToDateTime(TimeOnly.MinValue), + cycleEnd = cycle.CycleEnd.ToDateTime(TimeOnly.MinValue), + }, + commandType: CommandType.StoredProcedure, + commandTimeout: DataDbConnection.ImportCommandTimeoutSeconds, + cancellationToken: cancellationToken))).ToList(); + + var summaryCounts = await connection.QuerySingleAsync(new CommandDefinition( + SummaryCountsSql, + new + { + cycleStart = cycle.CycleStart.ToDateTime(TimeOnly.MinValue), + cycleEnd = cycle.CycleEnd.ToDateTime(TimeOnly.MinValue), + }, + commandTimeout: DataDbConnection.ImportCommandTimeoutSeconds, + cancellationToken: cancellationToken)); + + return ProjectListResponseFactory.Create( + cycle, + rows, + summaryCounts.ActiveNifa, + summaryCounts.AllNifa, + summaryCounts.PgmRecords, + summaryCounts.AlnCodes); + } + + private const string SummaryCountsSql = """ + SELECT + ActiveNifa = ( + SELECT COUNT(*) + FROM [data].[ActiveProjects] + WHERE ISNULL([ExcludeFromUi], 0) = 0 + ), + AllNifa = ( + SELECT COUNT(*) + FROM [data].[AllProjects] + WHERE ([ProjectEndDate] IS NULL OR [ProjectEndDate] >= @cycleStart) + AND ([ProjectStartDate] IS NULL OR [ProjectStartDate] <= @cycleEnd) + ), + PgmRecords = ( + SELECT COUNT(*) + FROM [data].[PGMProjects] + ), + AlnCodes = ( + SELECT COUNT(*) + FROM [data].[AssistanceListingNumbers] + WHERE [ProgramNumber] IS NOT NULL + ); + """; + + private sealed record ProjectListSummaryCounts( + int ActiveNifa, + int AllNifa, + int PgmRecords, + int AlnCodes); +} diff --git a/tests/server.tests/Data/AppDbContextTests.cs b/tests/server.tests/Data/AppDbContextTests.cs index c093f4c..a5d8368 100644 --- a/tests/server.tests/Data/AppDbContextTests.cs +++ b/tests/server.tests/Data/AppDbContextTests.cs @@ -56,6 +56,30 @@ public void ImportLog_maps_to_app_schema() entityType.GetTableName().Should().Be("ImportLog"); } + [Fact] + public void WorkflowRun_maps_to_app_schema() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.Model.FindEntityType(typeof(WorkflowRun)); + + entityType.Should().NotBeNull(); + entityType!.GetSchema().Should().Be(AppDbContext.AppSchema); + entityType.GetTableName().Should().Be("WorkflowRun"); + } + + [Fact] + public void WorkflowChecklistItemState_maps_to_app_schema() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.Model.FindEntityType(typeof(WorkflowChecklistItemState)); + + entityType.Should().NotBeNull(); + entityType!.GetSchema().Should().Be(AppDbContext.AppSchema); + entityType.GetTableName().Should().Be("WorkflowChecklistItemState"); + } + [Fact] public void ImportLog_has_index_for_latest_log_by_dataset() { @@ -74,6 +98,55 @@ public void ImportLog_has_index_for_latest_log_by_dataset() latestByDatasetIndex!.IsDescending.Should().Equal([false, true, true]); } + [Fact] + public void WorkflowChecklistItemState_has_unique_index_per_run_and_item() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.GetService().Model.FindEntityType(typeof(WorkflowChecklistItemState)); + var index = entityType?.GetIndexes() + .SingleOrDefault(item => item.Properties.Select(property => property.Name) + .SequenceEqual([ + nameof(WorkflowChecklistItemState.WorkflowRunId), + nameof(WorkflowChecklistItemState.ItemId), + ])); + + index.Should().NotBeNull(); + index!.IsUnique.Should().BeTrue(); + } + + [Fact] + public void WorkflowRun_has_filtered_unique_index_for_current_run() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.GetService().Model.FindEntityType(typeof(WorkflowRun)); + var index = entityType?.GetIndexes() + .SingleOrDefault(item => item.Properties.Select(property => property.Name) + .SequenceEqual([nameof(WorkflowRun.IsCurrent)])); + + index.Should().NotBeNull(); + index!.IsUnique.Should().BeTrue(); + index.GetFilter().Should().Be("[IsCurrent] = 1"); + } + + [Fact] + public void WorkflowChecklistItemState_has_optional_source_import_log_relationship() + { + using var db = TestDbContextFactory.CreateInMemory(); + + var entityType = db.Model.FindEntityType(typeof(WorkflowChecklistItemState)); + var foreignKey = entityType?.FindNavigation(nameof(WorkflowChecklistItemState.SourceImportLog)) + ?.ForeignKey; + + foreignKey.Should().NotBeNull(); + foreignKey!.PrincipalEntityType.ClrType.Should().Be(typeof(ImportLog)); + foreignKey.Properties.Should().ContainSingle(property => + property.Name == nameof(WorkflowChecklistItemState.SourceImportLogId)); + foreignKey.DeleteBehavior.Should().Be(DeleteBehavior.SetNull); + foreignKey.IsRequired.Should().BeFalse(); + } + [Fact] public void User_has_unique_index_on_entra_id() { diff --git a/tests/server.tests/Import/PgmProjectsControllerTests.cs b/tests/server.tests/Import/PgmProjectsControllerTests.cs index 63ad635..777c812 100644 --- a/tests/server.tests/Import/PgmProjectsControllerTests.cs +++ b/tests/server.tests/Import/PgmProjectsControllerTests.cs @@ -1,7 +1,11 @@ using FluentAssertions; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; using Server.Controllers; using Server.Core.Import; +using Server.Models.ProjectIdentification; +using Server.ProjectIdentification; +using System.Security.Claims; namespace Server.Tests.Import; @@ -11,24 +15,56 @@ public class PgmProjectsControllerTests public async Task Import_returns_bad_request_when_report_date_is_missing() { var importService = new FakeImportService(); - var controller = new PgmProjectsController(importService); + var projectIdentificationService = new FakeProjectIdentificationService(); + var controller = new PgmProjectsController( + importService, + projectIdentificationService, + NullLogger.Instance); var result = await controller.Import(null, CancellationToken.None); result.Result.Should().BeOfType(); importService.Invocations.Should().BeEmpty(); + projectIdentificationService.RecordedResults.Should().BeEmpty(); } [Fact] public async Task Import_runs_the_import_for_the_given_report_date() { var importService = new FakeImportService(); - var controller = new PgmProjectsController(importService); + var projectIdentificationService = new FakeProjectIdentificationService(); + var controller = new PgmProjectsController( + importService, + projectIdentificationService, + NullLogger.Instance); var reportDate = new DateOnly(2026, 6, 30); var result = await controller.Import(reportDate, CancellationToken.None); importService.Invocations.Should().Equal(reportDate); + projectIdentificationService.RecordedResults.Should().Equal(new PgmProjectsImportResult(42, reportDate)); + result.Result.Should().BeOfType() + .Which.Value.Should().BeEquivalentTo(new PgmProjectsImportResult(42, reportDate)); + } + + [Fact] + public async Task Import_returns_ok_when_workflow_recording_fails_after_import_succeeds() + { + var importService = new FakeImportService(); + var projectIdentificationService = new FakeProjectIdentificationService + { + ThrowOnRecord = true, + }; + var controller = new PgmProjectsController( + importService, + projectIdentificationService, + NullLogger.Instance); + var reportDate = new DateOnly(2026, 6, 30); + + var result = await controller.Import(reportDate, CancellationToken.None); + + importService.Invocations.Should().Equal(reportDate); + projectIdentificationService.RecordedResults.Should().Equal(new PgmProjectsImportResult(42, reportDate)); result.Result.Should().BeOfType() .Which.Value.Should().BeEquivalentTo(new PgmProjectsImportResult(42, reportDate)); } @@ -43,4 +79,42 @@ public Task ImportAsync(DateOnly reportDate, Cancellati return Task.FromResult(new PgmProjectsImportResult(42, reportDate)); } } + + private sealed class FakeProjectIdentificationService : IProjectIdentificationService + { + public List RecordedResults { get; } = []; + public bool ThrowOnRecord { get; init; } + + public Task GetSetupAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task ConfirmFiscalPeriodAsync( + string? fiscalYear, + ClaimsPrincipal user, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task SetChecklistItemCompletionAsync( + string itemId, + bool completed, + ClaimsPrincipal user, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public Task RecordPgmImportAsync( + PgmProjectsImportResult result, + ClaimsPrincipal user, + CancellationToken cancellationToken) + { + RecordedResults.Add(result); + if (ThrowOnRecord) + { + throw new InvalidOperationException("Workflow recording failed."); + } + + return Task.CompletedTask; + } + } } diff --git a/tests/server.tests/Models/FiscalYearCycleTests.cs b/tests/server.tests/Models/FiscalYearCycleTests.cs new file mode 100644 index 0000000..62bf12b --- /dev/null +++ b/tests/server.tests/Models/FiscalYearCycleTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Server.Models; + +namespace Server.Tests.Models; + +public class FiscalYearCycleTests +{ + [Theory] + [InlineData("FY00", "FY00", 1999, 10, 1, 2000, 9, 30)] + [InlineData("FY25", "FY25", 2024, 10, 1, 2025, 9, 30)] + [InlineData("fy26", "FY26", 2025, 10, 1, 2026, 9, 30)] + [InlineData("FY99", "FY99", 2098, 10, 1, 2099, 9, 30)] + [InlineData("FY2025", "FY25", 2024, 10, 1, 2025, 9, 30)] + public void TryParse_maps_fiscal_year_to_cycle_dates( + string input, + string expectedFiscalYear, + int startYear, + int startMonth, + int startDay, + int endYear, + int endMonth, + int endDay) + { + var parsed = FiscalYearCycle.TryParse(input, out var cycle); + + parsed.Should().BeTrue(); + var parsedCycle = cycle ?? throw new InvalidOperationException("The fiscal year should parse."); + parsedCycle.FiscalYear.Should().Be(expectedFiscalYear); + parsedCycle.CycleStart.Should().Be(new DateOnly(startYear, startMonth, startDay)); + parsedCycle.CycleEnd.Should().Be(new DateOnly(endYear, endMonth, endDay)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("FY")] + [InlineData("2027")] + [InlineData("fall-2026")] + [InlineData("FY0001")] + [InlineData("FY10000")] + public void TryParse_rejects_missing_or_invalid_values(string? input) + { + FiscalYearCycle.TryParse(input, out var cycle).Should().BeFalse(); + cycle.Should().BeNull(); + } +} diff --git a/tests/server.tests/ProjectIdentification/ProjectIdentificationServiceTests.cs b/tests/server.tests/ProjectIdentification/ProjectIdentificationServiceTests.cs new file mode 100644 index 0000000..fdc5d3c --- /dev/null +++ b/tests/server.tests/ProjectIdentification/ProjectIdentificationServiceTests.cs @@ -0,0 +1,208 @@ +using System.Security.Claims; +using FluentAssertions; +using Server.Core.Domain; +using Server.Core.Import; +using Server.Import; +using Server.Models; +using Server.Models.ProjectList; +using Server.ProjectIdentification; +using Server.ProjectList; + +namespace Server.Tests.ProjectIdentification; + +public class ProjectIdentificationServiceTests +{ + private static readonly ClaimsPrincipal User = new(new ClaimsIdentity( + [ + new Claim("oid", "11111111-1111-1111-1111-111111111111"), + new Claim("name", "Shannon Taylor"), + new Claim("preferred_username", "shannon@example.edu"), + ], + "Test")); + + [Fact] + public async Task Setup_creates_a_current_workflow_run_with_fiscal_period_active() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var service = CreateService(db); + + var setup = await service.GetSetupAsync(User, CancellationToken.None); + + setup.WorkflowRunId.Should().BePositive(); + setup.TotalCount.Should().Be(7); + setup.CompletedCount.Should().Be(0); + setup.ChecklistItems[0].Id.Should().Be("fiscal-period"); + setup.ChecklistItems[0].Status.Should().Be("active"); + setup.ChecklistItems[1].Status.Should().Be("locked"); + db.WorkflowRuns.Should().ContainSingle(run => run.IsCurrent); + } + + [Fact] + public async Task ConfirmFiscalPeriod_completes_the_first_item() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var service = CreateService(db); + + var setup = await service.ConfirmFiscalPeriodAsync("FY26", User, CancellationToken.None); + + setup.Should().NotBeNull(); + setup!.FiscalYear.Should().Be("FY26"); + setup.CycleStart.Should().Be(new DateOnly(2025, 10, 1)); + setup.CycleEnd.Should().Be(new DateOnly(2026, 9, 30)); + setup.CompletedCount.Should().Be(1); + setup.ChecklistItems[0].Status.Should().Be("done"); + setup.ChecklistItems[1].Status.Should().Be("active"); + } + + [Fact] + public async Task Import_items_require_previous_completion_and_successful_latest_import() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var service = CreateService(db); + AddImport(db, "all-projects", 1, "Succeeded", 100, DateTimeOffset.Parse("2026-06-01T12:00:00Z")); + await db.SaveChangesAsync(); + + var blocked = await service.SetChecklistItemCompletionAsync("all-projects", true, User, CancellationToken.None); + blocked.Should().BeNull(); + + await service.ConfirmFiscalPeriodAsync("FY26", User, CancellationToken.None); + var setup = await service.SetChecklistItemCompletionAsync("all-projects", true, User, CancellationToken.None); + + setup.Should().NotBeNull(); + var allProjects = setup!.ChecklistItems.Single(item => item.Id == "all-projects"); + allProjects.Status.Should().Be("done"); + allProjects.Source!.ImportLogId.Should().Be(1); + setup.ChecklistItems.Single(item => item.Id == "active-projects").Status.Should().Be("active"); + } + + [Fact] + public async Task Newer_import_attempt_makes_prior_completion_stale() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var service = CreateService(db); + AddImport(db, "all-projects", 1, "Succeeded", 100, DateTimeOffset.Parse("2026-06-01T12:00:00Z")); + await db.SaveChangesAsync(); + await service.ConfirmFiscalPeriodAsync("FY26", User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("all-projects", true, User, CancellationToken.None); + + AddImport(db, "all-projects", 2, "Succeeded", 110, DateTimeOffset.Parse("2026-06-02T12:00:00Z")); + await db.SaveChangesAsync(); + + var setup = await service.GetSetupAsync(User, CancellationToken.None); + + var allProjects = setup.ChecklistItems.Single(item => item.Id == "all-projects"); + allProjects.Completed.Should().BeFalse(); + allProjects.Stale.Should().BeTrue(); + allProjects.Status.Should().Be("stale"); + allProjects.StaleReason.Should().Contain("newer import"); + } + + [Fact] + public async Task Pgm_import_result_makes_pgm_item_ready_but_not_complete() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var service = CreateService(db); + + AddImport(db, "all-projects", 1, "Succeeded", 100, DateTimeOffset.Parse("2026-06-01T12:00:00Z")); + AddImport(db, "active-projects", 2, "Succeeded", 50, DateTimeOffset.Parse("2026-06-01T12:01:00Z")); + AddImport(db, "assistance-listing-numbers", 3, "Succeeded", 200, DateTimeOffset.Parse("2026-06-01T12:02:00Z")); + await db.SaveChangesAsync(); + + await service.ConfirmFiscalPeriodAsync("FY26", User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("all-projects", true, User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("active-projects", true, User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("assistance-listing-numbers", true, User, CancellationToken.None); + await service.RecordPgmImportAsync( + new PgmProjectsImportResult(1234, new DateOnly(2026, 9, 30)), + User, + CancellationToken.None); + + var setup = await service.GetSetupAsync(User, CancellationToken.None); + + var pgm = setup.ChecklistItems.Single(item => item.Id == "pgm-master-data"); + pgm.Ready.Should().BeTrue(); + pgm.Completed.Should().BeFalse(); + pgm.Status.Should().Be("ready"); + pgm.Source!.Rows.Should().Be(1234); + } + + [Fact] + public async Task Resolve_issues_requires_current_project_list_to_have_no_issues() + { + await using var db = TestDbContextFactory.CreateInMemory(); + var projectListService = new StubProjectListService { IssuesToResolve = 2 }; + var service = CreateService(db, projectListService); + + AddImport(db, "all-projects", 1, "Succeeded", 100, DateTimeOffset.Parse("2026-06-01T12:00:00Z")); + AddImport(db, "active-projects", 2, "Succeeded", 50, DateTimeOffset.Parse("2026-06-01T12:01:00Z")); + AddImport(db, "assistance-listing-numbers", 3, "Succeeded", 200, DateTimeOffset.Parse("2026-06-01T12:02:00Z")); + await db.SaveChangesAsync(); + + await service.ConfirmFiscalPeriodAsync("FY26", User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("all-projects", true, User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("active-projects", true, User, CancellationToken.None); + await service.SetChecklistItemCompletionAsync("assistance-listing-numbers", true, User, CancellationToken.None); + await service.RecordPgmImportAsync( + new PgmProjectsImportResult(1234, new DateOnly(2026, 9, 30)), + User, + CancellationToken.None); + await service.SetChecklistItemCompletionAsync("pgm-master-data", true, User, CancellationToken.None); + + var blocked = await service.SetChecklistItemCompletionAsync( + "resolve-project-issues", + true, + User, + CancellationToken.None); + projectListService.IssuesToResolve = 0; + var completed = await service.SetChecklistItemCompletionAsync( + "resolve-project-issues", + true, + User, + CancellationToken.None); + + blocked.Should().BeNull(); + completed.Should().NotBeNull(); + completed!.ChecklistItems.Single(item => item.Id == "resolve-project-issues") + .Status.Should().Be("done"); + } + + private static ProjectIdentificationService CreateService( + Server.Core.Data.AppDbContext db, + StubProjectListService? projectListService = null) => + new(db, new FlatFileImportRegistry(), projectListService ?? new StubProjectListService()); + + private static void AddImport( + Server.Core.Data.AppDbContext db, + string dataset, + int id, + string status, + int rows, + DateTimeOffset completedAt) + { + db.ImportLogs.Add(new ImportLog + { + Id = id, + Dataset = dataset, + Filename = $"{dataset}-{id}.csv", + Status = status, + AttemptedRows = rows, + RowsImported = status == "Succeeded" ? rows : null, + StartedAt = completedAt.AddMinutes(-1), + CompletedAt = completedAt, + }); + } + + private sealed class StubProjectListService : IProjectListService + { + public int IssuesToResolve { get; set; } + + public Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken) => + Task.FromResult(new ProjectListResponse( + cycle.FiscalYear, + cycle.CycleStart, + cycle.CycleEnd, + new ProjectListCountsDto(IssuesToResolve, 0, IssuesToResolve), + new ProjectListSummaryDto(0, 0, 0, 0, IssuesToResolve, []), + [])); + } +} diff --git a/tests/server.tests/ProjectList/ProjectListControllerTests.cs b/tests/server.tests/ProjectList/ProjectListControllerTests.cs new file mode 100644 index 0000000..1467d48 --- /dev/null +++ b/tests/server.tests/ProjectList/ProjectListControllerTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Server.Controllers; +using Server.Models; +using Server.Models.ProjectList; +using Server.ProjectList; + +namespace Server.Tests.ProjectList; + +public class ProjectListControllerTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("twenty-six")] + [InlineData("FY10000")] + public async Task Get_returns_bad_request_for_missing_or_invalid_fy(string? fy) + { + var controller = new ProjectListController(new StubProjectListService()); + + var result = await controller.Get(fy, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Get_passes_cycle_to_service_and_returns_response() + { + var service = new StubProjectListService(); + var controller = new ProjectListController(service); + + var result = await controller.Get("FY26", CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeSameAs(service.Response); + service.ReceivedCycle.Should().Be(new FiscalYearCycle( + "FY26", + new DateOnly(2025, 10, 1), + new DateOnly(2026, 9, 30))); + } + + private sealed class StubProjectListService : IProjectListService + { + public FiscalYearCycle? ReceivedCycle { get; private set; } + + public ProjectListResponse Response { get; } = new( + "FY26", + new DateOnly(2025, 10, 1), + new DateOnly(2026, 9, 30), + new ProjectListCountsDto(1, 1, 2), + new ProjectListSummaryDto(2, 3, 4, 5, 1, []), + [ + new ProjectListRowDto("CA-A-111-H", "1000001", "2025-1", "K1234", "Larkspur, S.", "ATM", "201", "Clean"), + new ProjectListRowDto("CA-B-222-CG", "1000002", "2025-2", null, "Okonkwo, Y.", "ANS", "204", "SFN mismatch"), + ]); + + public Task GetAsync(FiscalYearCycle cycle, CancellationToken cancellationToken) + { + ReceivedCycle = cycle; + return Task.FromResult(Response); + } + } +} diff --git a/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs b/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs new file mode 100644 index 0000000..f14b5d9 --- /dev/null +++ b/tests/server.tests/ProjectList/ProjectListResponseFactoryTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Server.Models; +using Server.Models.ProjectList; +using Server.ProjectList; + +namespace Server.Tests.ProjectList; + +public class ProjectListResponseFactoryTests +{ + [Fact] + public void Create_counts_tabs_and_sfn_distribution_from_rows() + { + if (!FiscalYearCycle.TryParse("FY26", out var cycle)) + { + throw new InvalidOperationException("FY26 should parse."); + } + + var rows = new[] + { + new ProjectListRowDto("CA-A-111-H", "1000001", "2025-1", "K1234", "Larkspur, S.", "ATM", "201", "Clean"), + new ProjectListRowDto("CA-B-222-CG", "1000002", "2025-2", null, "Okonkwo, Y.", "ANS", "204", "SFN mismatch"), + new ProjectListRowDto("CA-C-333-CG", "1000003", "2025-3", null, "Naidoo, T.", "VEN", "204", "No PGM match"), + }; + + var response = ProjectListResponseFactory.Create(cycle, rows, 10, 20, 30, 40); + + response.FiscalYear.Should().Be("FY26"); + response.CycleStart.Should().Be(new DateOnly(2025, 10, 1)); + response.CycleEnd.Should().Be(new DateOnly(2026, 9, 30)); + response.Counts.Should().Be(new ProjectListCountsDto(2, 1, 3)); + response.Summary.ActiveNifa.Should().Be(10); + response.Summary.AllNifa.Should().Be(20); + response.Summary.PgmRecords.Should().Be(30); + response.Summary.AlnCodes.Should().Be(40); + response.Summary.IssuesToResolve.Should().Be(2); + response.Summary.SfnDistribution.Should().Equal( + new SfnDistributionDto("201", 1), + new SfnDistributionDto("204", 2)); + response.Rows.Should().Equal(rows); + } +}