diff --git a/app/package-lock.json b/app/package-lock.json index aa6b2dedf..9ec07fd58 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -16,16 +16,23 @@ "@types/jest": "^29.5.8", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.9.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", "api-problem": "^9.0.2", "axios": "^1.5.1", "compression": "^1.7.4", "config": "^3.3.9", "cors": "^2.8.5", + "date-fns": "^2.30.0", "express": "^4.18.2", "express-winston": "^4.2.0", "helmet": "^7.0.0", "jsonwebtoken": "^9.0.2", + "knex": "^3.1.0", + "objection": "^3.1.3", + "pg": "^8.11.3", "ts-node": "^10.9.1", + "uuid": "^9.0.1", "winston": "^3.11.0", "winston-transport": "^4.6.0" }, @@ -634,6 +641,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", @@ -1636,6 +1654,68 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/pg": { + "version": "8.10.9", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", + "integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/pg/node_modules/pg-types": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", + "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.0.1", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/pg/node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/pg/node_modules/postgres-date": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", + "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/@types/pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "engines": { + "node": ">=12" + } + }, "node_modules/@types/qs": { "version": "6.9.10", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", @@ -1693,6 +1773,11 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.4.tgz", "integrity": "sha512-HlJjF3wxV4R2VQkFpKe0YqJLilYNgtRtsqqZtby7RkVsSs+i+vbyzjtUwpFEdUCKcrGzCiEJE7F/0mKjh0sunA==" }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==" + }, "node_modules/@types/yargs": { "version": "17.0.29", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", @@ -1957,6 +2042,42 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2539,6 +2660,14 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-writer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", + "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==", + "engines": { + "node": ">=4" + } + }, "node_modules/bundle-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", @@ -2800,6 +2929,11 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" + }, "node_modules/colorspace": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", @@ -2820,6 +2954,14 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "engines": { + "node": ">=14" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -2987,11 +3129,30 @@ "node": ">= 8" } }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/db-errors": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/db-errors/-/db-errors-0.2.3.tgz", + "integrity": "sha512-OOgqgDuCavHXjYSJoV2yGhv6SeG8nk42aoCSoyXLZUH7VwFG27rxbavU1z+VrZbZjphw5UkDQwUlD21MwZpUng==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3486,7 +3647,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } @@ -5741,6 +5901,14 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -6037,8 +6205,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { "version": "1.3.0", @@ -6410,7 +6577,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, "engines": { "node": ">=8.0.0" } @@ -6443,6 +6609,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6778,6 +6949,14 @@ "node": ">= 0.4" } }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6877,7 +7056,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -8002,6 +8180,64 @@ "node": ">=6" } }, + "node_modules/knex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", + "integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "engines": { + "node": ">=8" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -8477,6 +8713,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/objection": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/objection/-/objection-3.1.3.tgz", + "integrity": "sha512-X4DH8/xKBS34bwWOSLAPyceg0JgLhLiUuz+cEEyDA8iDFoT1UM9UbtwBpwHV11hYskAKxOgVlNHeveFQiOPDXA==", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "db-errors": "^0.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "knex": ">=1.0.1" + } + }, + "node_modules/objection/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/objection/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8611,6 +8888,11 @@ "node": ">=6" } }, + "node_modules/packet-reader": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz", + "integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8679,8 +8961,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.10.1", @@ -8721,6 +9002,97 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", + "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "dependencies": { + "buffer-writer": "2.0.0", + "packet-reader": "1.0.0", + "pg-connection-string": "^2.6.2", + "pg-pool": "^3.6.1", + "pg-protocol": "^1.6.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz", + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz", + "integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", + "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -8811,6 +9183,46 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-range": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", + "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8931,7 +9343,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "dev": true, "engines": { "node": ">=6" } @@ -9046,6 +9457,17 @@ "node": ">=8.10.0" } }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -9066,6 +9488,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -9101,11 +9528,18 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -9613,6 +10047,14 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -9858,7 +10300,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9947,6 +10388,14 @@ "node": ">=6" } }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9978,6 +10427,14 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "engines": { + "node": ">=8" + } + }, "node_modules/titleize": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", @@ -10458,7 +10915,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -10476,6 +10932,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", @@ -10734,7 +11202,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "dev": true, "engines": { "node": ">=0.4" } diff --git a/app/package.json b/app/package.json index 44b8509d4..281e8f29d 100644 --- a/app/package.json +++ b/app/package.json @@ -31,16 +31,23 @@ "@types/jest": "^29.5.8", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.9.0", + "@types/pg": "^8.10.9", + "@types/uuid": "^9.0.7", "api-problem": "^9.0.2", "axios": "^1.5.1", "compression": "^1.7.4", "config": "^3.3.9", "cors": "^2.8.5", + "date-fns": "^2.30.0", "express": "^4.18.2", "express-winston": "^4.2.0", "helmet": "^7.0.0", "jsonwebtoken": "^9.0.2", + "knex": "^3.1.0", + "objection": "^3.1.3", + "pg": "^8.11.3", "ts-node": "^10.9.1", + "uuid": "^9.0.1", "winston": "^3.11.0", "winston-transport": "^4.6.0" }, diff --git a/app/src/components/crypt.ts b/app/src/components/crypt.ts new file mode 100644 index 000000000..8a90d895c --- /dev/null +++ b/app/src/components/crypt.ts @@ -0,0 +1,80 @@ +import config from 'config'; +import crypto from 'crypto'; + +// GCM mode is good for situations with random access and authenticity requirements +// CBC mode is older, but is sufficiently secure with high performance for short payloads +const algorithm = 'aes-256-cbc'; +const encoding = 'base64'; +const encodingCheck = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}={2})$/; +const hashAlgorithm = 'sha256'; + +/** + * @function encrypt + * Yields an encrypted string containing the iv and ciphertext, separated by a colon. + * If no key is provided, ciphertext will be the plaintext in base64 encoding. + * @param {string} text The input string contents + * @returns {string} The encrypted base64 formatted string in the format `iv:ciphertext`. + */ +export function encrypt(text: string) { + if (isEncrypted(text)) return text; + + const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined; + if (passphrase && passphrase.length) { + let content = Buffer.from(text); + const iv = crypto.randomBytes(16); + const hash = crypto.createHash(hashAlgorithm); + // AES-256 key length must be exactly 32 bytes + const key = hash.update(passphrase).digest().subarray(0, 32); + const cipher = crypto.createCipheriv(algorithm, key, iv); + content = Buffer.concat([cipher.update(text), cipher.final()]); + return `${iv.toString(encoding)}:${content.toString(encoding)}`; + } else { + return text; + } +} + +/** + * @function decrypt + * Yields the plaintext by accepting an encrypted string containing the iv and + * ciphertext, separated by a colon. If no key is provided, the plaintext will be + * the ciphertext. + * @param {string} text The input encrypted string contents + * @returns {string} The decrypted plaintext string, usually in utf-8 + */ +export function decrypt(text: string) { + if (!isEncrypted(text)) return text; + + const passphrase = config.has('server.passphrase') ? (config.get('server.passphrase') as string) : undefined; + if (passphrase && passphrase.length) { + const [iv, encrypted] = text.split(':').map((p) => Buffer.from(p, encoding)); + let content = encrypted; + const hash = crypto.createHash(hashAlgorithm); + // AES-256 key length must be exactly 32 bytes + const key = hash.update(passphrase).digest().subarray(0, 32); + const decipher = crypto.createDecipheriv(algorithm, key, iv); + content = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return Buffer.from(content, encoding).toString(); + } else { + return text; + } +} + +/** + * @function isEncrypted + * A predicate function for determining if the input text is encrypted + * @param {string} text The input string contents + * @returns {boolean} True if encrypted, false if not + */ +export function isEncrypted(text: string) { + if (!text) return false; + if (typeof text !== 'string') return false; + const textParts = text.split(':'); + return ( + textParts.length == 2 && + textParts[0] && + textParts[1] && + textParts[0].length === 24 && // Base64 encoding of a 16 byte IV should be 24 + encodingCheck.test(textParts[0]) && + encodingCheck.test(textParts[1]) + ); +} diff --git a/app/src/db/.editorconfig b/app/src/db/.editorconfig new file mode 100644 index 000000000..fedd3a8c3 --- /dev/null +++ b/app/src/db/.editorconfig @@ -0,0 +1,2 @@ +[*.js] +indent_size = unset diff --git a/app/src/db/dataConnection.ts b/app/src/db/dataConnection.ts new file mode 100644 index 000000000..76a510e6c --- /dev/null +++ b/app/src/db/dataConnection.ts @@ -0,0 +1,176 @@ +import { Knex, knex } from 'knex'; +import { Model } from 'objection'; + +import { getLogger } from '../components/log'; +import { knexConfig } from '../knexfile'; +import models from './models'; + +const log = getLogger(module.filename); + +export default class DataConnection { + /** + * Creates a new DataConnection with default (Postgresql) Knex configuration. + * @class + */ + private static _instance: DataConnection; + private _knex: Knex; + private _connected: boolean = false; + + constructor() { + if (!DataConnection._instance) { + this._knex = knex(knexConfig); + DataConnection._instance = this; + } + + return DataConnection._instance; + } + + /** + * @function connected + * True or false if connected. + */ + get connected() { + return this._connected; + } + + /** + * @function knex + * Gets the current knex binding + */ + get knex() { + return this._knex; + } + + /** + * @function knex + * Sets the current knex binding and forwards to Objection model + * @param {object} v - a Knex object. + */ + set knex(v) { + this._knex = v; + this._connected = false; + Model.knex(this._knex); + } + + /** + * @function checkAll + * Checks the Knex connection, the database schema, and Objection models + * @returns {boolean} True if successful, otherwise false + */ + async checkAll() { + const modelsOk = !!this._knex; + const [connectOk, schemaOk] = await Promise.all([this.checkConnection(), this.checkSchema()]); + this._connected = connectOk && schemaOk && modelsOk; + log.verbose(`Connect OK: ${connectOk}, Schema OK: ${schemaOk}, Models OK: ${modelsOk}`, { function: 'checkAll' }); + + if (!connectOk) { + log.error('Could not connect to the database, check configuration and ensure database server is running', { + function: 'checkAll' + }); + } + if (!schemaOk) { + log.error('Connected to the database, could not verify the schema. Ensure proper migrations have been run.', { + function: 'checkAll' + }); + } + if (!modelsOk) { + log.error('Connected to the database, schema is ok, could not initialize Knex Models.', { function: 'checkAll' }); + } + + return this._connected; + } + + /** + * @function checkConnection + * Checks the current knex connection to Postgres + * If the connected DB is in read-only mode, transaction_read_only will not be off + * @returns {boolean} True if successful, otherwise false + */ + async checkConnection() { + try { + const data = await this._knex.raw('show transaction_read_only'); + const result = data?.rows[0]?.transaction_read_only === 'off'; + if (result) { + log.debug('Database connection ok', { function: 'checkConnection' }); + } else { + log.warn('Database connection is read-only', { function: 'checkConnection' }); + } + this._connected = result; + return result; + } catch (err) { + log.error(`Error with database connection: ${err.message}`, { function: 'checkConnection' }); + this._connected = false; + return false; + } + } + + /** + * @function checkSchema + * Queries the knex connection to check for the existence of the expected schema tables + * @returns {boolean} True if schema is ok, otherwise false + */ + checkSchema() { + try { + const tables = Object.values(models).map((model) => model.tableName); + return Promise.all( + tables.map((table) => + Promise.all(knexConfig.searchPath.map((schema) => this._knex.schema.withSchema(schema).hasTable(table))) + ) + ) + .then((exists) => exists.every((table) => table.some((exist) => exist))) + .then((result) => { + if (result) log.debug('Database schema ok', { function: 'checkSchema' }); + return result; + }); + } catch (err) { + log.error(`Error with database schema: ${err.message}`, { function: 'checkSchema' }); + log.error(err); + return false; + } + } + + /** + * @function checkModel + * Attaches the Objection model to the existing knex connection + * @returns {boolean} True if successful, otherwise false + */ + checkModel() { + try { + Model.knex(this._knex); + log.debug('Database models ok', { function: 'checkModel' }); + return true; + } catch (err) { + log.error(`Error attaching Model to connection: ${err.message}`, { function: 'checkModel' }); + log.error(err); + return false; + } + } + + /** + * @function close + * Will close the DataConnection + * @param {function} [cb] Optional callback + */ + close(cb = undefined) { + if (this._knex) { + this._knex.destroy(() => { + this._knex = undefined; + log.info('Disconnected', { function: 'close' }); + if (cb) cb(); + }); + } else if (cb) cb(); + } + + /** + * @function resetConnection + * Invalidates and reconnects existing knex connection + */ + resetConnection() { + if (this._knex) { + log.warn('Attempting to reset database connection pool', { function: 'resetConnection' }); + this._knex.destroy(() => { + this._knex.initialize(); + }); + } + } +} diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts new file mode 100644 index 000000000..dda3adbf9 --- /dev/null +++ b/app/src/db/models/index.ts @@ -0,0 +1,8 @@ +import Submission from './tables/submission'; + +const models = { + // Tables + Submission: Submission +}; + +export default { models }; diff --git a/app/src/db/models/jsonSchema.ts b/app/src/db/models/jsonSchema.ts new file mode 100644 index 000000000..9d7685089 --- /dev/null +++ b/app/src/db/models/jsonSchema.ts @@ -0,0 +1,8 @@ +const stamps = { + createdBy: { type: ['string', 'null'], maxLength: 255 }, + createdAt: { type: ['string', 'null'] }, + updatedBy: { type: ['string', 'null'], maxLength: 255 }, + updatedAt: { type: ['string', 'null'] } +}; + +export default { stamps }; diff --git a/app/src/db/models/mixins/encrypt.ts b/app/src/db/models/mixins/encrypt.ts new file mode 100644 index 000000000..d34b9c7a6 --- /dev/null +++ b/app/src/db/models/mixins/encrypt.ts @@ -0,0 +1,68 @@ +import { encrypt, decrypt } from '../../../components/crypt'; + +/** + * Encrypt Objection Model Plugin + * Add column encryption handlers to an Objection Model + * + * This class will automatically encrypt and decrypt specified column fields + * during insert/update/get operations transparently. + * Inspired by @see {@link https://github.com/Dialogtrail/objection-encrypt} + * + * @see module:knex + * @see module:objection + */ +export default function Encrypt(opts) { + // Provide good default options if possible. + const options = Object.assign( + { + fields: [] + }, + opts + ); + + // Return the mixin + return (Model) => { + return class extends Model { + async $beforeInsert(context) { + await super.$beforeInsert(context); + this.encryptFields(); + } + async $afterInsert(context) { + await super.$afterInsert(context); + return this.decryptFields(); + } + async $beforeUpdate(queryOptions, context) { + await super.$beforeUpdate(queryOptions, context); + this.encryptFields(); + } + async $afterUpdate(queryOptions, context) { + await super.$afterUpdate(queryOptions, context); + return this.decryptFields(); + } + async $afterFind(context) { + await super.$afterFind(context); + return this.decryptFields(); + } + + /** + * Encrypts specified fields + */ + encryptFields() { + options.fields.forEach((field) => { + const value = this[field]; + if (value) this[field] = encrypt(value); + }); + } + + /** + * Decrypts specified fields + */ + decryptFields() { + options.fields.forEach((field) => { + const value = this[field]; + if (value) this[field] = decrypt(value); + }); + } + }; + }; +} diff --git a/app/src/db/models/mixins/index.ts b/app/src/db/models/mixins/index.ts new file mode 100644 index 000000000..905144b7f --- /dev/null +++ b/app/src/db/models/mixins/index.ts @@ -0,0 +1,2 @@ +export { default as Encrypt } from './encrypt'; +export { default as Timestamps } from './timestamps'; diff --git a/app/src/db/models/mixins/timestamps.ts b/app/src/db/models/mixins/timestamps.ts new file mode 100644 index 000000000..85fc8f85b --- /dev/null +++ b/app/src/db/models/mixins/timestamps.ts @@ -0,0 +1,25 @@ +/** + * Timestamps Objection Model Plugin + * Add handlers to an Objection Model + * + * In order to use JSON Schema Validation, we need to treat Timestamps as strings. + * They still get stored as dates/timestamps, but in/out of the database they need to be strings. + * + * This class will set the createdAt timestamp/string before insert and updatedAt before update. + * The JSON Schema validation will pass as it goes through the marshalling, expecting createdAt and updateAt as strings. + * + * @see module:knex + * @see module:objection + */ +export default function Timestamps(Model) { + return class extends Model { + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + this.createdAt = new Date().toISOString(); + } + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + this.updatedAt = new Date().toISOString(); + } + }; +} diff --git a/app/src/db/models/tables/submission.ts b/app/src/db/models/tables/submission.ts new file mode 100644 index 000000000..60f64d25c --- /dev/null +++ b/app/src/db/models/tables/submission.ts @@ -0,0 +1,35 @@ +import { mixin, Model } from 'objection'; + +import jsonSchema from '../jsonSchema'; +import { Timestamps } from '../mixins'; +import { filterOneOrMany } from '../utils'; + +export default class Submission extends mixin(Model, Timestamps) { + static get tableName() { + return 'submission'; + } + + static get idColumn() { + return 'submissionId'; + } + + static get modifiers() { + return { + filterSubmissionIds(query: any, value: any) { + filterOneOrMany(query, value, 'submission.submissionId'); + } + }; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['submissionId'], + properties: { + submissionId: { type: 'string', minLength: 1, maxLength: 255 }, + ...jsonSchema.stamps + }, + additionalProperties: false + }; + } +} diff --git a/app/src/db/models/utils.ts b/app/src/db/models/utils.ts new file mode 100644 index 000000000..d8c174470 --- /dev/null +++ b/app/src/db/models/utils.ts @@ -0,0 +1,85 @@ +const { Model } = require('objection'); + +/** + * @function filterILike + * Conditionally adds a where or where in clause to the `query` if `value` is a string + * or an array of strings respectively + * @param {object} query The Objection Query Builder + * @param {string|string[]} value The string or array of string values to match on + * @param {string} column The table column to match on + */ +export function filterOneOrMany(query, value, column) { + if (value) { + if (Array.isArray(value) && value.length) { + query.whereIn(column, value); + } else { + query.where(column, value); + } + } +} + +/** + * @function filterILike + * Conditionally adds a where ilike clause to the `query` builder if `value` is not falsy + * ilike is a Postgres keyword for case-insensitive matching + * @see {@link https://www.postgresql.org/docs/current/functions-matching.html} + * @param {object} query The Objection Query Builder + * @param {string} value The string value to match on + * @param {string} column The table column to match on + */ +export function filterILike(query, value, column) { + if (value) query.where(column, 'ilike', `%${value}%`); +} + +export function inArrayClause(column, values) { + return values.map((p) => `'${p}' = ANY("${column}")`).join(' or '); +} + +export function inArrayFilter(column, values) { + const clause = utils.inArrayClause(column, values); + return `(array_length("${column}", 1) > 0 and (${clause}))`; +} + +/** + * @function redactSecrets + * Sanitizes objects by replacing sensitive data with a REDACTED string value + * @param {object} data An arbitrary object + * @param {string[]} fields An array of field strings to sanitize on + * @returns {object} An arbitrary object with specified secret fields marked as redacted + */ +export function redactSecrets(data, fields) { + if (fields && Array.isArray(fields) && fields.length) { + fields.forEach((field) => { + if (data[field]) data[field] = 'REDACTED'; + }); + } + return data; +} + +export function toArray(values) { + if (values) { + return Array.isArray(values) + ? values.filter((p) => p && p.trim().length > 0) + : [values].filter((p) => p && p.trim().length > 0); + } + return []; +} + +/** + * @function trx + * Wraps Objection/Knex queries in an Objection Transaction object + * @param {*} callback The objection queries that we want to enclose in a transaction + * @returns {Promise { + // dates must be in the date only part of 2020-05-16T13:18:27.160Z + return format(parseJSON(value), 'yyyy-MM-dd'); +}); +// timestamps... +types.setTypeParser(1114, (value) => new Date(value).toISOString()); +// timestamps with zone +types.setTypeParser(1184, (value) => new Date(value).toISOString()); + +const logWrapper = (level: string, msg: string) => log.log(level, msg); + +export const knexConfig: Knex.Config = { + client: 'pg', + connection: { + host: config.get('db.host'), + user: config.get('db.username'), + password: config.get('db.password'), + database: config.get('db.database'), + port: config.get('db.port') + }, + debug: ['silly', 'debug'].includes(config.get('server.logLevel')), + log: { + debug: (msg) => logWrapper('debug', msg), + deprecate: (msg) => logWrapper('warn', msg), + error: (msg) => logWrapper('error', msg), + warn: (msg) => logWrapper('warn', msg) + }, + migrations: { + directory: __dirname + '/src/db/migrations' + }, + pool: { + min: parseInt(config.get('db.poolMin')), + max: parseInt(config.get('db.poolMax')) + // This shouldn't be here: https://github.com/knex/knex/issues/3455#issuecomment-535554401 + // propagateCreateError: false + }, + searchPath: ['public'], // Define postgres schemas to match on + seeds: { + directory: __dirname + '/src/db/seeds' + } +};