Skip to content

Commit

Permalink
IR-496: Download DPS & NOMIS config as JSON (#169)
Browse files Browse the repository at this point in the history
* Refactor NOMIS config download routes into a self-contained nested router
* Add utility function to convert objects with nested Dates into strings
* Allow downloading NOMIS report configuration in source JSON format
* Add methods to retrieve incident reporting constants from API
* Allow downloading incident reporting api constants as source JSON
* Add script to import DPS constants/enumerations into typescript types
* Add script to list all report configuration URLs in an environment and document import steps

```mermaid
flowchart TD
    Download[Download DPS config JSON via UI app] --> Import[Import files using CLI from UI app locally]
    Import --> Git[Check updated enumerations and constants into git]
    Git --> End([ ])
```

Steps to get latest incident reporting API constants imported:
- download DPS constants/enumerations
- run script to import them into generated typescript files
- check into git
- latest types from incident reporting api are now baked into this repo

Steps to get latest NOMIS configuration imported:
- download NOMIS JSON files
- [NOT IMPLEMENTED] run script to import them into generated typescript files
- [NOT IMPLEMENTED] check into git
- [NOT IMPLEMENTED] latest configuration from NOMIS are now baked into this repo

As noted in the README, download links are listed using `./scripts/listDownloadLinks.ts prod` command.
  • Loading branch information
ushkarev authored Sep 12, 2024
1 parent 2143472 commit beecea9
Show file tree
Hide file tree
Showing 29 changed files with 1,352 additions and 560 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ Each user has 3 caseloads, Leeds(LEI), Brixton(BXI) and Moorland(MDI)
| IR_VIEWER | password123456 | VIEW_INCIDENT_REPORT<br/>GLOBAL_SEARCH |
| IR_CREATOR | password123456 | CREATE_INCIDENT_REPORT<br/>APPROVE_INCIDENT_REPORT<br/>GLOBAL_SEARCH |

### Import configuration from external sources

#### Download DPS & NOMIS configuration as CSV or JSON

Prints links to download configuration files:

```shell
./scripts/listDownloadLinks.ts <env>
```

#### Import DPS configuration JSON files

Import the DPS JSON files downloaded above with:

```shell
./scripts/importDpsConstants.ts <type> <file path>
```

This will create typescript definitions for the latest constants and enumerations.
Make sure to check these into git.

#### Import NOMIS configuration JSON files

TODO: not yet implemented

### Updating dependencies

It’s prudent to periodically update npm dependencies; continuous integration will occasionally warn when it’s needed.
Expand Down
31 changes: 26 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@
"redis": "^4.7.0",
"superagent": "^10.1.0",
"url-value-parser": "^2.2.0",
"uuid": "^10.0.0"
"uuid": "^10.0.0",
"yaml": "^2.5.1"
},
"devDependencies": {
"@jgoz/esbuild-plugin-typecheck": "^4.0.1",
Expand Down Expand Up @@ -165,6 +166,7 @@
"prettier-plugin-jinja-template": "^1.5.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.0",
"typescript": "^5.6.2"
}
}
115 changes: 115 additions & 0 deletions scripts/importDpsConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env npx tsx
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'

import type { IncidentReportingApi, Constant, TypeConstant } from '../server/data/incidentReportingApi'

type ConstantsMethod = keyof IncidentReportingApi['constants']
interface Template {
method: ConstantsMethod
enumName: string
documentation: string
}
const templates: Template[] = [
{ method: 'types', enumName: 'Type', documentation: 'Types of reportable incidents' },
{ method: 'statuses', enumName: 'Status', documentation: 'Report statuses' },
{
method: 'informationSources',
enumName: 'InformationSource',
documentation: 'Whether the report was first created in DPS or NOMIS',
},
{
method: 'staffInvolvementRoles',
enumName: 'StaffInvolvementRole',
documentation: 'Roles of staff involvement in an incident',
},
{
method: 'prisonerInvolvementRoles',
enumName: 'PrisonerInvolvementRole',
documentation: 'Roles of a prisoner’s involvement in an incident',
},
{
method: 'prisonerInvolvementOutcomes',
enumName: 'PrisonerInvolvementOutcome',
documentation: 'Outcomes from a prisoner’s involvement in an incident',
},
{
method: 'correctionRequestReasons',
enumName: 'CorrectionRequestReason',
documentation: 'Reasons for correction requests made about a report',
},
{
method: 'errorCodes',
enumName: 'ErrorCode',
documentation: 'Unique codes to discriminate errors returned from the incident reporting api',
},
]

const scriptName = path.basename(process.argv[1])

function printHelp(): never {
const help = `
Imports constants from incident reporting api downloaded as JSON files.
Usage:
./scripts/${scriptName} <type> <file path>
Where <type> is one of ${templates.map(template => template.method).join(', ')}
`.trim()
process.stderr.write(`${help}\n`)
process.exit(1)
}

const [, , type, filePath] = process.argv
if (!type || !filePath) {
printHelp()
}
const template = templates.find(t => t.method === type)
if (!template) {
printHelp()
}
const { method, enumName, documentation } = template

const constants: (Constant | TypeConstant)[] = JSON.parse(fs.readFileSync(filePath, { encoding: 'utf8' }))

const outputPath = path.resolve(__dirname, `../server/reportConfiguration/constants/${method}.ts`)
const outputFile = fs.openSync(outputPath, 'w')

fs.writeSync(outputFile, `// Generated with ./scripts/${scriptName} at ${new Date().toISOString()}\n\n`)
if (method === 'errorCodes') {
// error codes are numbers so need special treatment

fs.writeSync(outputFile, `/** ${documentation} */\n`)
fs.writeSync(outputFile, '// eslint-disable-next-line import/prefer-default-export\n')
fs.writeSync(outputFile, `export enum ${enumName} {\n`)
constants.forEach(constant => {
fs.writeSync(outputFile, `${constant.description} = ${constant.code},\n`)
})
fs.writeSync(outputFile, '}\n')
} else {
// other constants are strings with extra info

fs.writeSync(outputFile, `/** ${documentation} */\n`)
fs.writeSync(outputFile, `export const ${method} = [\n`)
constants.forEach(constant => {
fs.writeSync(
outputFile,
`{ code: ${JSON.stringify(constant.code)}, description: ${JSON.stringify(constant.description)},\n`,
)
if ('active' in constant) {
fs.writeSync(outputFile, `active: ${constant.active}, nomisCode: ${JSON.stringify(constant.nomisCode)} },\n`)
} else {
fs.writeSync(outputFile, '},\n')
}
})
fs.writeSync(outputFile, '] as const\n\n')
fs.writeSync(outputFile, `/** ${documentation} */\n`)
fs.writeSync(outputFile, `export type ${enumName} = (typeof ${method})[number]['code']\n`)
if (method === 'types') {
fs.writeSync(outputFile, `\n/** ${documentation}\n * @deprecated\n */\n`)
fs.writeSync(outputFile, `export type Nomis${enumName} = (typeof ${method})[number]['nomisCode']\n`)
}
}
fs.closeSync(outputFile)

spawnSync('npx', ['prettier', '--write', outputPath], { encoding: 'utf8' })
83 changes: 83 additions & 0 deletions scripts/listDownloadLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env npx tsx
import fs from 'node:fs'
import path from 'node:path'

import { parse as parseYaml } from 'yaml'

const scriptName = path.basename(process.argv[1])

const helmRootPath = path.resolve(__dirname, '../helm_deploy')
const environments = fs
.readdirSync(helmRootPath, { encoding: 'utf8' })
.filter(p => p.startsWith('values-') && p.endsWith('.yaml'))
.map(p => {
const { environment } = /^values-(?<environment>.*?).yaml$/.exec(p).groups
const helmValuesPath = path.join(helmRootPath, p)
const helmValues: { 'generic-service': { ingress: { host: string } } } = parseYaml(
fs.readFileSync(helmValuesPath, { encoding: 'utf8' }),
)
const baseUrl = `https://${helmValues['generic-service'].ingress.host}`
return { environment, baseUrl }
})

function printHelp(): never {
const help = `
Prints download links for DPS and NOMIS configuration:
Usage:
./scripts/${scriptName} <env>
Where <env> is one of ${environments.map(e => e.environment).join(', ')}
`.trim()
process.stderr.write(`${help}\n`)
process.exit(1)
}

const [, , env] = process.argv
if (!env) {
printHelp()
}
const environment = environments.find(e => e.environment === env)
if (!environment) {
printHelp()
}
const { baseUrl } = environment

process.stderr.write('DPS configuration JSON downloads:\n')
;[
'types',
'statuses',
'informationSources',
'staffInvolvementRoles',
'prisonerInvolvementRoles',
'prisonerInvolvementOutcomes',
'correctionRequestReasons',
'errorCodes',
].forEach(type => {
process.stderr.write(` - ${baseUrl}/download-report-config/dps/${type}.json\n`)
})

process.stderr.write('\nNOMIS configuration JSON downloads:\n')
;[
'incident-types',
'incident-type/<type>/questions',
'incident-type/<type>/prisoner-roles',
'staff-involvement-roles',
'prisoner-involvement-roles',
'prisoner-involvement-outcome',
].forEach(urlSlug => {
process.stderr.write(` - ${baseUrl}/download-report-config/nomis/${urlSlug}.json\n`)
})
process.stderr.write('Where <type> is the NOMIS incident report type code.\n')

process.stderr.write('\nNOMIS configuration CSV downloads:\n')
;[
'incident-types',
'incident-type/<type>/questions',
'incident-type/<type>/prisoner-roles',
'staff-involvement-roles',
'prisoner-involvement-roles',
'prisoner-involvement-outcome',
].forEach(urlSlug => {
process.stderr.write(` - ${baseUrl}/download-report-config/nomis/${urlSlug}.csv\n`)
})
process.stderr.write('Where <type> is the NOMIS incident report type code.\n')
21 changes: 9 additions & 12 deletions server/@types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@ export declare global {
/**
* Replaces nested Date types with string (preserving nullability).
* Needed because api responses over-the-wire are JSON and therefore encode dates as plain strings.
* NB: arrays of Date are not supported
*/
type DatesAsStrings<T> = {
[k in keyof T]: T[k] extends Array<infer U>
? Array<DatesAsStrings<U>>
: T[k] extends Date | null
? string | null
: T[k] extends Date
? string
: T[k] extends object
? DatesAsStrings<T[k]>
: T[k]
}
type DatesAsStrings<T> = T extends Date | null
? string | null
: T extends Date
? string
: T extends Array<infer U>
? Array<DatesAsStrings<U>>
: T extends object
? { [k in keyof T]: DatesAsStrings<T[k]> }
: T
}
Loading

0 comments on commit beecea9

Please sign in to comment.