From 63ad7c794daf77ea364055de787394095f5d3cd9 Mon Sep 17 00:00:00 2001 From: Jim Lerza Date: Wed, 27 Nov 2024 16:55:16 -0500 Subject: [PATCH 1/4] opex: updated the event-codes-by-year and count-event-codes-by-year scripts to accept a --fiscal flag to optionally output data over a fiscal year period, and added a cases-closed-in-year report with the same --fiscal flag. adjusted to use node:util parseArgs for parameter parsing --- scripts/reports/cases-closed-in-year.ts | 150 +++++++++++++ scripts/reports/count-event-codes-by-year.ts | 154 ++++++++----- scripts/reports/event-codes-by-year.ts | 224 +++++++++++++------ web-api/elasticsearch/efcms-case-mappings.ts | 6 + 4 files changed, 409 insertions(+), 125 deletions(-) create mode 100644 scripts/reports/cases-closed-in-year.ts diff --git a/scripts/reports/cases-closed-in-year.ts b/scripts/reports/cases-closed-in-year.ts new file mode 100644 index 00000000000..b0c43a03639 --- /dev/null +++ b/scripts/reports/cases-closed-in-year.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env npx ts-node --transpile-only + +// usage: npx ts-node --transpile-only scripts/reports/cases-closed-in-year.ts 2024 [-f] + +import { CASE_STATUS_TYPES } from '@shared/business/entities/EntityConstants'; +import { DateTime } from 'luxon'; +import { + ServerApplicationContext, + createApplicationContext, +} from '@web-api/applicationContext'; +import { generateCsv } from '../helpers/generate-csv'; +import { pick } from 'lodash'; +import { searchAll } from '@web-api/persistence/elasticsearch/searchClient'; +import { validateDateAndCreateISO } from '@shared/business/utilities/DateHandler'; + +const year = process.argv[2] || `${DateTime.now().toObject().year}`; +const fiscal = + !!process.argv[3] && + (process.argv[3] === '-f' || process.argv[3] === '--fiscal'); +const OUTPUT_DIR = `${process.env.HOME}/Documents`; +const CLOSED_STATUSES: string[] = [ + CASE_STATUS_TYPES.closed, + CASE_STATUS_TYPES.closedDismissed, +]; +const BEGIN = validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? `${Number(year) - 1}` : year, +})!; +const END = validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? year : `${Number(year) + 1}`, +})!; + +const getAllCasesClosedInFiscalYear = async ({ + applicationContext, +}: { + applicationContext: ServerApplicationContext; +}): Promise => { + const { results }: { results: RawCase[] } = await searchAll({ + applicationContext, + searchParameters: { + body: { + query: { + bool: { + must: [ + { + term: { + 'entityName.S': 'Case', + }, + }, + { + terms: { + 'caseStatusHistory.L.M.updatedCaseStatus.S': CLOSED_STATUSES, + }, + }, + { + range: { + 'caseStatusHistory.L.M.date.S': { + gte: BEGIN, + lt: END, + }, + }, + }, + ], + }, + }, + sort: [{ 'sortableDocketNumber.N': 'asc' }], + }, + index: 'efcms-case', + }, + }); + console.log( + `Found ${results.length} cases with a "closed" status history record and` + + ` a status history record generated in fiscal year ${year}`, + ); + return results.filter(c => wasClosedThisFiscalYear(c)); +}; + +const wasClosedThisFiscalYear = (c: RawCase): boolean => { + let closedThisFiscalYear = false; + for (const csh of c.caseStatusHistory) { + if ( + csh.date && + csh.date >= BEGIN && + csh.date < END && + csh.updatedCaseStatus && + CLOSED_STATUSES.includes(csh.updatedCaseStatus) + ) { + closedThisFiscalYear = true; + break; + } + } + return closedThisFiscalYear; +}; + +const outputCsv = ({ + casesClosedInYear, +}: { + casesClosedInYear: RawCase[]; +}): void => { + const filename = `${OUTPUT_DIR}/cases-closed-in-${fiscal ? 'fy-' : ''}${year}.csv`; + const columns = [ + { header: 'Docket Number', key: 'docketNumber' }, + { header: 'Case Title', key: 'caption' }, + { header: 'Judge', key: 'judge' }, + { header: 'Case Status', key: 'status' }, + { header: 'Closed Date', key: 'closedDateHumanized' }, + ]; + const rows = casesClosedInYear.map(c => { + const judge = + c.associatedJudge + ?.replace('Chief Special Trial ', '') + .replace('Special Trial ', '') + .replace('Judge ', '') || ''; + const closedDateHumanized = + c.caseStatusHistory + .reverse() + .find( + csh => + CLOSED_STATUSES.includes(csh.updatedCaseStatus) && + csh.date >= BEGIN && + csh.date < END, + ) + ?.date.split('T')[0] || ''; + const caption = c.caseCaption.replace(/\r\n|\r|\n/g, ' ').trim(); + return { + ...pick(c, ['docketNumber', 'status']), + caption, + closedDateHumanized, + judge, + }; + }); + generateCsv({ columns, filename, rows }); + console.log(`Generated ${filename}`); +}; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const applicationContext = createApplicationContext({}); + const casesClosedInYear = await getAllCasesClosedInFiscalYear({ + applicationContext, + }); + console.log( + `Filtered results to ${casesClosedInYear.length} cases having a "closed"` + + ` status history record that was generated in fiscal year ${year}`, + ); + outputCsv({ casesClosedInYear }); +})(); diff --git a/scripts/reports/count-event-codes-by-year.ts b/scripts/reports/count-event-codes-by-year.ts index 18aadd8fd94..26cb29ae20a 100755 --- a/scripts/reports/count-event-codes-by-year.ts +++ b/scripts/reports/count-event-codes-by-year.ts @@ -1,6 +1,9 @@ #!/usr/bin/env npx ts-node --transpile-only -// usage: scripts/reports/count-event-codes-by-year.ts m01,m02 feew [-y 2000-2020] +// usage examples: +// scripts/reports/count-event-codes-by-year.ts NOA -f -y 2024 +// scripts/reports/count-event-codes-by-year.ts m01,m02,FEEW -y 2000-2020 +// scripts/reports/count-event-codes-by-year.ts M071,M074 --years 2021,2022,2024 import { DateTime } from 'luxon'; import { @@ -15,11 +18,19 @@ import { validateDateAndCreateISO } from '@shared/business/utilities/DateHandler requireEnvVars(['ENV', 'REGION']); -let positionals, values; - const config = { allowPositionals: true, options: { + fiscal: { + default: false, + short: 'f', + type: 'boolean', + }, + help: { + default: false, + short: 'h', + type: 'boolean', + }, stricken: { default: false, short: 's', @@ -39,35 +50,84 @@ const config = { strict: true, } as const; -function usage(warning: string | undefined) { +const usage = (warning?: string): void => { if (warning) { console.log(warning); } - console.log(`Usage: ${process.argv[1]} M071,m074 `); - console.log('Options:', JSON.stringify(config, null, 4)); -} + console.log(`Usage: ${process.argv[1]} M071,m074 [-f -y 2000-2022]`); + console.log('Options:', JSON.stringify(config, null, 2)); +}; + +const parseArguments = (): { + eventCodes: string[]; + fiscal: boolean; + stricken: boolean; + years: number[]; +} => { + let positionals: string[]; + let values: { + [k: string]: any; + fiscal: boolean; + help: boolean; + stricken: boolean; + verbose: boolean; + years: string; + }; + try { + ({ positionals, values } = parseArgs(config)); + } catch (ex) { + usage(`Error: ${ex}`); + process.exit(1); + } + if (values.verbose) { + usage('Verbose output enabled'); + console.log('positionals:', positionals); + console.log('values:', values); + } + if (values.help) { + if (!values.verbose) { + usage(); + } + process.exit(0); + } + if (!positionals || positionals.length === 0) { + const errorMessage = 'invalid input: expected event codes'; + if (values.verbose) { + console.log(errorMessage); + } else { + usage(errorMessage); + } + process.exit(1); + } + return { + eventCodes: positionals[0].split(',').map(s => s.toUpperCase()), + fiscal: values.fiscal, + stricken: values.stricken, + years: parseIntsArg(values.years), + }; +}; const getCountDocketEntriesByEventCodesAndYears = async ({ applicationContext, eventCodes, + fiscal, onlyNonStricken, years, }: { applicationContext: ServerApplicationContext; eventCodes: string[]; + fiscal: boolean; onlyNonStricken: boolean; years?: number[]; }): Promise => { const must: {}[] = [ { bool: { - should: eventCodes.map(eventCode => { - return { - term: { - 'eventCode.S': eventCode, - }, - }; - }), + should: eventCodes.map(eventCode => ({ + term: { + 'eventCode.S': eventCode, + }, + })), }, }, ]; @@ -85,13 +145,13 @@ const getCountDocketEntriesByEventCodesAndYears = async ({ 'receivedAt.S': { gte: validateDateAndCreateISO({ day: '1', - month: '1', - year: String(years[0]), + month: fiscal ? '10' : '1', + year: fiscal ? `${years[0] - 1}` : `${years[0]}`, }), lt: validateDateAndCreateISO({ day: '1', - month: '1', - year: String(years[0] + 1), + month: fiscal ? '10' : '1', + year: fiscal ? `${years[0]}` : `${years[0] + 1}`, }), }, }, @@ -99,24 +159,22 @@ const getCountDocketEntriesByEventCodesAndYears = async ({ } else { must.push({ bool: { - should: years.map(year => { - return { - range: { - 'receivedAt.S': { - gte: validateDateAndCreateISO({ - day: '1', - month: '1', - year: String(year), - }), - lt: validateDateAndCreateISO({ - day: '1', - month: '1', - year: String(year + 1), - }), - }, + should: years.map(year => ({ + range: { + 'receivedAt.S': { + gte: validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? `${year - 1}` : `${year}`, + }), + lt: validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? `${year}` : `${year + 1}`, + }), }, - }; - }), + }, + })), }, }); } @@ -131,40 +189,22 @@ const getCountDocketEntriesByEventCodesAndYears = async ({ }, index: 'efcms-docket-entry', }; - // console.log('Effective Query:', JSON.stringify(searchParameters, null, 4)); - const results = await count({ + return await count({ applicationContext, searchParameters, }); - return results; }; // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { - try { - ({ positionals, values } = parseArgs(config)); - } catch (ex) { - usage(`Error: ${ex}`); - process.exit(1); - } - if (positionals.length === 0) { - usage('invalid input: expected event codes'); - process.exit(1); - } - const eventCodes = positionals[0].split(',').map(s => s.toUpperCase()); - const years: number[] = parseIntsArg(values.years); - const includeStricken = values.stricken; + const { eventCodes, fiscal, stricken, years } = parseArguments(); const ret = await getCountDocketEntriesByEventCodesAndYears({ applicationContext: createApplicationContext({}), eventCodes, - onlyNonStricken: !includeStricken, + fiscal, + onlyNonStricken: !stricken, years, }); - if (values.verbose) { - usage('Verbose output enabled'); - console.log(`positionals: ${positionals}`); - console.log(`option values: ${values}`); - } console.log(ret); })(); diff --git a/scripts/reports/event-codes-by-year.ts b/scripts/reports/event-codes-by-year.ts index 66a707a71ed..84ba511f8ca 100755 --- a/scripts/reports/event-codes-by-year.ts +++ b/scripts/reports/event-codes-by-year.ts @@ -1,9 +1,16 @@ #!/usr/bin/env npx ts-node --transpile-only -// usage: scripts/reports/event-codes-by-year.ts M071,M074 [-y 2021-2022] > ~/Desktop/m071s-and-m074s-filed-2021-2022.csv +// usage examples: +// scripts/reports/event-codes-by-year.ts NOA -f -y 2024 +// scripts/reports/event-codes-by-year.ts M071,M074 -y 2021-2022 +// scripts/reports/event-codes-by-year.ts M071,M074 -y 2021,2022,2024 import { DateTime } from 'luxon'; -import { createApplicationContext } from '@web-api/applicationContext'; +import { + ServerApplicationContext, + createApplicationContext, +} from '@web-api/applicationContext'; +import { generateCsv } from '../helpers/generate-csv'; import { parseArgs } from 'node:util'; import { parseIntsArg } from './reportUtils'; import { requireEnvVars } from '../../shared/admin-tools/util'; @@ -12,13 +19,28 @@ import { searchAll, } from '@web-api/persistence/elasticsearch/searchClient'; import { validateDateAndCreateISO } from '@shared/business/utilities/DateHandler'; +import PQueue from 'p-queue'; requireEnvVars(['ENV', 'REGION']); -let positionals, values; const config = { allowPositionals: true, options: { + fiscal: { + default: false, + short: 'f', + type: 'boolean', + }, + help: { + default: false, + short: 'h', + type: 'boolean', + }, + verbose: { + default: false, + short: 'v', + type: 'boolean', + }, years: { default: `${DateTime.now().toObject().year}`, short: 'y', @@ -28,21 +50,71 @@ const config = { strict: true, } as const; -function usage(warning: string | undefined) { +const usage = (warning?: string) => { if (warning) { console.log(warning); } - console.log(`Usage: ${process.argv[1]} M071,m074 [-y 2023,2024]`); - console.log('Options:', JSON.stringify(config, null, 4)); -} + console.log(`Usage: ${process.argv[1]} M071,m074 [-f -y 2023,2024]`); + console.log('Options:', JSON.stringify(config, null, 2)); +}; + +const parseArguments = (): { + eventCodes: string[]; + fiscal: boolean; + years: number[]; +} => { + let positionals: string[]; + let values: { + [k: string]: any; + fiscal: boolean; + help: boolean; + verbose: boolean; + years: string; + }; + try { + ({ positionals, values } = parseArgs(config)); + } catch (ex) { + usage(`Error: ${ex}`); + process.exit(1); + } + if (values.verbose) { + usage('Verbose output enabled'); + console.log('positionals:', positionals); + console.log('values:', values); + } + if (values.help) { + if (!values.verbose) { + usage(); + } + process.exit(0); + } + if (!positionals || positionals.length === 0) { + const errorMessage = 'invalid input: expected event codes'; + if (values.verbose) { + console.log(errorMessage); + } else { + usage(errorMessage); + } + process.exit(1); + } + return { + eventCodes: positionals[0].split(',').map(s => s.toUpperCase()), + fiscal: values.fiscal, + years: parseIntsArg(values.years), + }; +}; + +const OUTPUT_DIR = `${process.env.HOME}/Documents`; +const CONCURRENCY = 8; const cachedCases: { [key: string]: RawCase } = {}; +const rows: { [k: string]: string }[] = []; const getCase = async ({ applicationContext, docketNumber, }: { - applicationContext: IApplicationContext; + applicationContext: ServerApplicationContext; docketNumber: string; }): Promise => { if (docketNumber in cachedCases) { @@ -77,10 +149,12 @@ const getCase = async ({ const getDocketEntriesByEventCodesAndYears = async ({ applicationContext, eventCodes, + fiscal, years, }: { - applicationContext: IApplicationContext; + applicationContext: ServerApplicationContext; eventCodes: string[]; + fiscal: boolean; years?: number[]; }): Promise => { const must: {}[] = [ @@ -103,13 +177,13 @@ const getDocketEntriesByEventCodesAndYears = async ({ 'receivedAt.S': { gte: validateDateAndCreateISO({ day: '1', - month: '1', - year: String(years[0]), + month: fiscal ? '10' : '1', + year: fiscal ? `${years[0] - 1}` : `${years[0]}`, }), lt: validateDateAndCreateISO({ day: '1', - month: '1', - year: String(years[0] + 1), + month: fiscal ? '10' : '1', + year: fiscal ? `${years[0]}` : `${years[0] + 1}`, }), }, }, @@ -117,24 +191,22 @@ const getDocketEntriesByEventCodesAndYears = async ({ } else { must.push({ bool: { - should: years.map(year => { - return { - range: { - 'receivedAt.S': { - gte: validateDateAndCreateISO({ - day: '1', - month: '1', - year: String(year), - }), - lt: validateDateAndCreateISO({ - day: '1', - month: '1', - year: String(year + 1), - }), - }, + should: years.map(year => ({ + range: { + 'receivedAt.S': { + gte: validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? `${year - 1}` : `${year}`, + }), + lt: validateDateAndCreateISO({ + day: '1', + month: fiscal ? '10' : '1', + year: fiscal ? `${year}` : `${year + 1}`, + }), }, - }; - }), + }, + })), }, }); } @@ -156,51 +228,67 @@ const getDocketEntriesByEventCodesAndYears = async ({ return results; }; -// eslint-disable-next-line @typescript-eslint/no-floating-promises -(async () => { - try { - ({ positionals, values } = parseArgs(config)); - } catch (ex) { - usage(`Error: ${ex}`); - process.exit(1); +const addRowForDocketEntry = async ({ + applicationContext, + de, +}: { + applicationContext: ServerApplicationContext; + de: RawDocketEntry; +}): Promise => { + if (!('docketNumber' in de) || !de.docketNumber) { + return; } - if (positionals.length === 0) { - usage('invalid input: expected event codes'); - process.exit(1); + const c = await getCase({ + applicationContext, + docketNumber: de.docketNumber, + }); + if (!c) { + return; } - const eventCodes = positionals[0].split(',').map(s => s.toUpperCase()); - const years: number[] = parseIntsArg(values.years); - const applicationContext = createApplicationContext({}); + const judge = + c.associatedJudge + ?.replace('Chief Special Trial ', '') + .replace('Special Trial ', '') + .replace('Judge ', '') || ''; + rows.push({ + caption: c.caseCaption.replace(/\r\n|\r|\n/g, ' ').trim(), + docketNumber: c.docketNumber, + documentType: de.documentType, + filed: de.receivedAt.split('T')[0], + judge, + status: c.status, + }); +}; +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const applicationContext = createApplicationContext({}); + const { eventCodes, fiscal, years } = parseArguments(); const docketEntries = await getDocketEntriesByEventCodesAndYears({ applicationContext, eventCodes, + fiscal, years, }); - - console.log( - '"Docket Number","Date Filed","Document Type","Associated Judge",' + - '"Case Status","Case Caption"', + console.log(`Found ${docketEntries.length} docket entries.`); + const queue = new PQueue({ concurrency: CONCURRENCY }); + const funcs = docketEntries.map( + (de: RawDocketEntry) => async () => + await addRowForDocketEntry({ applicationContext, de }), ); - for (const de of docketEntries) { - if (!('docketNumber' in de)) { - continue; - } - const c = await getCase({ - applicationContext, - docketNumber: de.docketNumber, - }); - if (!c) { - continue; - } - const associatedJudge = c.associatedJudge - ?.replace('Chief Special Trial ', '') - .replace('Special Trial ', '') - .replace('Judge ', ''); - console.log( - `"${c.docketNumberWithSuffix}","${de.receivedAt.split('T')[0]}",` + - `"${de.documentType}","${associatedJudge}","${c.status}",` + - `"${c.caseCaption}"`, - ); - } + await queue.addAll(funcs); + + const columns = [ + { header: 'Docket Number', key: 'docketNumber' }, + { header: 'Date Filed', key: 'filed' }, + { header: 'Document Type', key: 'documentType' }, + { header: 'Judge', key: 'judge' }, + { header: 'Status', key: 'status' }, + { header: 'Case Title', key: 'caption' }, + ]; + const filename = + `${OUTPUT_DIR}/${eventCodes.map(ec => ec.toLowerCase()).join('-')}-filed-` + + `in-${fiscal ? 'fy-' : ''}${years.join('-')}.csv`; + generateCsv({ columns, filename, rows }); + console.log(`Generated ${filename}`); })(); diff --git a/web-api/elasticsearch/efcms-case-mappings.ts b/web-api/elasticsearch/efcms-case-mappings.ts index 51c8f1671e0..c637e0c693f 100644 --- a/web-api/elasticsearch/efcms-case-mappings.ts +++ b/web-api/elasticsearch/efcms-case-mappings.ts @@ -34,6 +34,12 @@ export const efcmsCaseMappings = { 'caseCaption.S': { type: 'text', }, + 'caseStatusHistory.L.M.date.S': { + type: 'date', + }, + 'caseStatusHistory.L.M.updatedCaseStatus.S': { + type: 'keyword', + }, 'caseType.S': { type: 'keyword', }, From adb234d47a1ed5d585be7b90f1f67840d0f4480a Mon Sep 17 00:00:00 2001 From: Jim Lerza Date: Wed, 20 Nov 2024 14:43:07 -0500 Subject: [PATCH 2/4] opex: added a helper for generating CSV files and converted a couple of reports to use the helper --- scripts/helpers/generate-csv.ts | 48 +++++++++++++++++++++++++++++++++ scripts/reports/closed-dates.ts | 44 ++++++++++++++---------------- scripts/reports/stale-cases.ts | 24 ++++++++--------- 3 files changed, 79 insertions(+), 37 deletions(-) create mode 100644 scripts/helpers/generate-csv.ts diff --git a/scripts/helpers/generate-csv.ts b/scripts/helpers/generate-csv.ts new file mode 100644 index 00000000000..30b0359fca1 --- /dev/null +++ b/scripts/helpers/generate-csv.ts @@ -0,0 +1,48 @@ +import { appendFileSync, existsSync, unlinkSync } from 'fs'; + +const compileOutput = ({ + columns, + rows, +}: { + columns: { header: string; key: string }[]; + rows: { [k: string]: any }[]; +}): string => { + const headers = columns.map(c => c.header); + const keys = columns.map(c => c.key); + let output = `"${headers.join('","')}"`; + for (const row of rows) { + const values: string[] = []; + for (const key of keys) { + const value = row[key] || ''; + values.push(`${value}`); + } + output += `\n"${values.join('","')}"`; + } + return output; +}; + +const writeFile = ({ + contents, + filename, +}: { + contents: string; + filename: string; +}): void => { + if (existsSync(filename)) { + unlinkSync(filename); + } + appendFileSync(filename, contents); +}; + +export const generateCsv = ({ + columns, + filename, + rows, +}: { + columns: { header: string; key: string }[]; + filename: string; + rows: { [k: string]: any }[]; +}): void => { + const contents = compileOutput({ columns, rows }); + writeFile({ contents, filename }); +}; diff --git a/scripts/reports/closed-dates.ts b/scripts/reports/closed-dates.ts index f2bdb4c54d3..cbae97d77f0 100644 --- a/scripts/reports/closed-dates.ts +++ b/scripts/reports/closed-dates.ts @@ -5,7 +5,7 @@ import { ServerApplicationContext, createApplicationContext, } from '@web-api/applicationContext'; -import { appendFileSync } from 'fs'; +import { generateCsv } from '../helpers/generate-csv'; import { searchAll } from '@web-api/persistence/elasticsearch/searchClient'; import { validateDateAndCreateISO } from '@shared/business/utilities/DateHandler'; @@ -58,33 +58,29 @@ const getAllCasesOpenedInYear = async ({ return results; }; -const outputCsv = ({ - casesOpenedInYear, - filename, -}: { - casesOpenedInYear: RawCase[]; - filename: string; -}): void => { - let output = - '"Docket Number","Date Created","Date Closed","Case Title",' + - '"Case Status","Case Type"'; - for (const c of casesOpenedInYear) { - const rcvdAtHumanized = c.receivedAt.split('T')[0]; - const closedHumanized = c.closedDate?.split('T')[0] || ''; - output += - `\n"${c.docketNumber}","${rcvdAtHumanized}","${closedHumanized}",` + - `"${c.caseCaption}","${c.status}","${c.caseType}"`; - } - appendFileSync(`${OUTPUT_DIR}/${filename}`, output); -}; - // eslint-disable-next-line @typescript-eslint/no-floating-promises (async () => { const applicationContext = createApplicationContext({}); const casesOpenedInYear = await getAllCasesOpenedInYear({ applicationContext, }); - const filename = `closed-dates-of-cases-opened-in-${year}.csv`; - outputCsv({ casesOpenedInYear, filename }); - console.log(`Generated ${OUTPUT_DIR}/${filename}`); + const filename = `${OUTPUT_DIR}/closed-dates-of-cases-opened-in-${year}.csv`; + const columns = [ + { header: 'Docket Number', key: 'docketNumber' }, + { header: 'Date Created', key: 'rcvdAtHumanized' }, + { header: 'Date Closed', key: 'closedHumanized' }, + { header: 'Case Title', key: 'caseCaption' }, + { header: 'Case Status', key: 'status' }, + { header: 'Case Type', key: 'caseType' }, + ]; + const rows = casesOpenedInYear.map(c => ({ + caseCaption: c.caseCaption, + caseType: c.caseType, + closedHumanized: c.closedDate?.split('T')[0] || '', + docketNumber: c.docketNumber, + rcvdAtHumanized: c.receivedAt.split('T')[0], + status: c.status, + })); + generateCsv({ columns, filename, rows }); + console.log(`Generated ${filename}`); })(); diff --git a/scripts/reports/stale-cases.ts b/scripts/reports/stale-cases.ts index f76a222299f..8cbded869ce 100644 --- a/scripts/reports/stale-cases.ts +++ b/scripts/reports/stale-cases.ts @@ -9,12 +9,12 @@ import { ServerApplicationContext, createApplicationContext, } from '@web-api/applicationContext'; -import { appendFileSync, existsSync, unlinkSync } from 'fs'; import { calculateDifferenceInDays, createISODateString, } from '@shared/business/utilities/DateHandler'; import { compareStrings } from '@shared/business/utilities/sortFunctions'; +import { generateCsv } from '../helpers/generate-csv'; import { search, searchAll, @@ -168,18 +168,16 @@ const isCaseStale = async ({ console.log(`Found ${staleCases.length} stale cases.`); console.log(`Writing CSV to ${OUTPUT_FILENAME}...`); - const sortedStaleCases = staleCases + const columns = [ + { header: 'Judge', key: 'judge' }, + { header: 'Docket Number', key: 'docketNumber' }, + { header: 'Caption', key: 'caption' }, + { header: 'Status', key: 'status' }, + { header: 'Last Filed', key: 'deRcvdAt' }, + { header: 'Age in Days', key: 'deAge' }, + ]; + const rows = staleCases .sort((a, b) => b.deAge - a.deAge) .sort((a, b) => compareStrings(a.judge, b.judge)); - let output = - '"Judge","Docket Number","Caption","Status","Last Filed","Age in Days"'; - for (const sc of sortedStaleCases) { - output += - `\n"${sc.judge}","${sc.docketNumber}","${sc.caption}","${sc.status}",` + - `"${sc.deRcvdAt}","${sc.deAge}"`; - } - if (existsSync(OUTPUT_FILENAME)) { - unlinkSync(OUTPUT_FILENAME); - } - appendFileSync(OUTPUT_FILENAME, output); + generateCsv({ columns, filename: OUTPUT_FILENAME, rows }); })(); From d61e3afcaeb481754739b2314ec52e4839b6d2eb Mon Sep 17 00:00:00 2001 From: Jim Lerza Date: Wed, 20 Nov 2024 22:42:27 -0500 Subject: [PATCH 3/4] opex: added tests for the generate-csv helper --- scripts/helpers/generate-csv.test.ts | 92 ++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 scripts/helpers/generate-csv.test.ts diff --git a/scripts/helpers/generate-csv.test.ts b/scripts/helpers/generate-csv.test.ts new file mode 100644 index 00000000000..5c6c0d80d14 --- /dev/null +++ b/scripts/helpers/generate-csv.test.ts @@ -0,0 +1,92 @@ +import { generateCsv } from './generate-csv'; +import fs from 'fs'; + +const exists = jest.spyOn(fs, 'existsSync').mockImplementation(jest.fn()); +const unlink = jest.spyOn(fs, 'unlinkSync').mockImplementation(jest.fn()); +const append = jest.spyOn(fs, 'appendFileSync').mockImplementation(jest.fn()); + +const MOCK_COLUMNS = [ + { header: 'Droid name', key: 'name' }, + { header: 'Droid type', key: 'type' }, + { header: 'Alliance', key: 'alliance' }, +]; +const MOCK_ROWS = [ + { + alliance: 'Rebellion', + name: 'C-3PO', + restrained: true, + type: 'Protocol', + }, + { + alliance: 'Rebellion', + name: 'R2-D2', + restrained: false, + type: 'Astromech', + }, + { + alliance: 'Rebellion', + name: 'C1-10P', + restrained: false, + type: 'Astromech', + }, + { + alliance: 'Empire', + name: 'IG-88', + restrained: false, + type: 'Assassin', + }, + { + alliance: 'Empire', + name: 'MSE-6', + restrained: true, + type: 'Mouse', + }, +]; +const MOCK_FILENAME = `${process.env.HOME}/tmp/jest.csv`; +const MOCK_CONTENTS = + '"Droid name","Droid type","Alliance"' + + '\n"C-3PO","Protocol","Rebellion"' + + '\n"R2-D2","Astromech","Rebellion"' + + '\n"C1-10P","Astromech","Rebellion"' + + '\n"IG-88","Assassin","Empire"' + + '\n"MSE-6","Mouse","Empire"'; + +describe('generateCsv', () => { + beforeEach(() => { + exists.mockReturnValue(true); + }); + + it('deletes the specified output file if it already exists', () => { + generateCsv({ + columns: MOCK_COLUMNS, + filename: MOCK_FILENAME, + rows: MOCK_ROWS, + }); + + expect(unlink).toHaveBeenCalled(); + expect(append).toHaveBeenCalled(); + }); + + it('does not attempt to delete the specified output file if it does not already exist', () => { + exists.mockReturnValueOnce(false); + + generateCsv({ + columns: MOCK_COLUMNS, + filename: MOCK_FILENAME, + rows: MOCK_ROWS, + }); + + expect(unlink).not.toHaveBeenCalled(); + expect(append).toHaveBeenCalled(); + }); + + it('compiles an array of objects into a CSV with the given columns', () => { + generateCsv({ + columns: MOCK_COLUMNS, + filename: MOCK_FILENAME, + rows: MOCK_ROWS, + }); + + expect(append).toHaveBeenCalledWith(MOCK_FILENAME, MOCK_CONTENTS); + }); +}); From a6e552a0beb1884da4af797ec87c3f945dd384bd Mon Sep 17 00:00:00 2001 From: Jim Lerza Date: Wed, 20 Nov 2024 22:51:43 -0500 Subject: [PATCH 4/4] opex: test when an expected key doesn't exist --- scripts/helpers/generate-csv.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/helpers/generate-csv.test.ts b/scripts/helpers/generate-csv.test.ts index 5c6c0d80d14..2fc827b0f45 100644 --- a/scripts/helpers/generate-csv.test.ts +++ b/scripts/helpers/generate-csv.test.ts @@ -36,7 +36,6 @@ const MOCK_ROWS = [ type: 'Assassin', }, { - alliance: 'Empire', name: 'MSE-6', restrained: true, type: 'Mouse', @@ -49,7 +48,7 @@ const MOCK_CONTENTS = '\n"R2-D2","Astromech","Rebellion"' + '\n"C1-10P","Astromech","Rebellion"' + '\n"IG-88","Assassin","Empire"' + - '\n"MSE-6","Mouse","Empire"'; + '\n"MSE-6","Mouse",""'; describe('generateCsv', () => { beforeEach(() => {