generated from alphane/template
Initial commit
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
api/node_modules
|
||||||
|
api/dist
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
|
node_modules
|
||||||
|
docs
|
||||||
|
|
||||||
|
/now.*
|
||||||
|
/*.now.*
|
||||||
|
**/now.*
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
env:
|
||||||
|
browser: true
|
||||||
|
es2021: true
|
||||||
|
node: true
|
||||||
|
extends:
|
||||||
|
- prettier
|
||||||
|
overrides: []
|
||||||
|
parserOptions:
|
||||||
|
ecmaVersion: latest
|
||||||
|
sourceType: module
|
||||||
|
rules: {}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories + files
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
app
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
db/sapassword.env
|
||||||
|
.env.development
|
||||||
|
|
||||||
|
# DB Data
|
||||||
|
db/data
|
||||||
|
/db_dumps
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# .prettierrc or .prettierrc.yaml
|
||||||
|
$schema: "https://json.schemastore.org/prettierrc"
|
||||||
|
embeddedLanguageFormatting: "auto"
|
||||||
|
trailingComma: "es5"
|
||||||
|
tabWidth: 2
|
||||||
|
semi: false
|
||||||
|
singleQuote: false
|
||||||
|
singleAttributePerLine: true
|
||||||
|
useTabs: false
|
||||||
|
endOfLine: "auto"
|
||||||
|
printWidth: 100
|
||||||
|
plugins:
|
||||||
|
- prettier-plugin-embed
|
||||||
|
- prettier-plugin-sql
|
||||||
|
overrides:
|
||||||
|
- files: "*.sql"
|
||||||
|
options:
|
||||||
|
language: "tsql"
|
||||||
|
paramTypes: "{ named: [':'] }"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
nodejs 20.10.0
|
||||||
|
ruby 3.2.2
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"eslint.workingDirectories": ["./api", "./web"],
|
||||||
|
"eslint.validate": ["javascript", "typescript", "vue"],
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"prettier.configPath": ".prettierrc.yaml",
|
||||||
|
"prettier.requireConfig": true,
|
||||||
|
"prettier.resolveGlobalModules": true,
|
||||||
|
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# Stage 0 - base node customizations
|
||||||
|
FROM node:20.19.4-alpine3.22 AS base-node
|
||||||
|
|
||||||
|
RUN npm install -g npm@10.2.5
|
||||||
|
|
||||||
|
# Stage 1 - api build - requires development environment because typescript
|
||||||
|
FROM base-node AS api-build-stage
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
WORKDIR /usr/src/api
|
||||||
|
|
||||||
|
COPY api/package*.json ./
|
||||||
|
COPY api/tsconfig*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY api ./
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Copy html files, remove once we are using Vite for build process
|
||||||
|
COPY api/src/templates ./dist/templates
|
||||||
|
|
||||||
|
# Stage 2 - web build - requires development environment to install vue-cli-service
|
||||||
|
FROM base-node AS web-build-stage
|
||||||
|
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
WORKDIR /usr/src/web
|
||||||
|
|
||||||
|
COPY web/package*.json ./
|
||||||
|
COPY web/tsconfig*.json ./
|
||||||
|
COPY web/vite.config.js ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY web ./
|
||||||
|
|
||||||
|
# Switching to production mode for build environment.
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3 - production setup
|
||||||
|
FROM base-node
|
||||||
|
|
||||||
|
ARG RELEASE_TAG
|
||||||
|
ARG GIT_COMMIT_HASH
|
||||||
|
|
||||||
|
ENV RELEASE_TAG=${RELEASE_TAG}
|
||||||
|
ENV GIT_COMMIT_HASH=${GIT_COMMIT_HASH}
|
||||||
|
|
||||||
|
# Persists TZ=UTC effect after container build and into container run
|
||||||
|
# Ensures dates/times are consistently formated as UTC
|
||||||
|
# Conversion to local time should happen in the UI
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
USER node
|
||||||
|
|
||||||
|
WORKDIR /home/node/app
|
||||||
|
RUN chown -R node:node /home/node/app
|
||||||
|
|
||||||
|
COPY --from=api-build-stage --chown=node:node /usr/src/api/package*.json ./
|
||||||
|
RUN npm install && npm cache clean --force --loglevel=error
|
||||||
|
|
||||||
|
COPY --from=api-build-stage --chown=node:node /usr/src/api/dist ./dist/
|
||||||
|
COPY --from=web-build-stage --chown=node:node /usr/src/web/dist ./dist/web/
|
||||||
|
|
||||||
|
RUN echo "RELEASE_TAG=${RELEASE_TAG}" >> VERSION
|
||||||
|
RUN echo "GIT_COMMIT_HASH=${GIT_COMMIT_HASH}" >> VERSION
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
COPY --from=api-build-stage --chown=node:node /usr/src/api/bin/boot-app.sh ./bin/
|
||||||
|
|
||||||
|
RUN chmod +x ./bin/boot-app.sh
|
||||||
|
|
||||||
|
CMD ["./bin/boot-app.sh"]
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
|
||||||
|
// https://github.com/typescript-eslint/typescript-eslint/issues/251
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
es2020: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||||
|
overrides: [],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
plugins: ["@typescript-eslint"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
args: "all",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
caughtErrors: "all",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
destructuredArrayIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ "$RUN_SCHEDULER" = "true" ]; then
|
||||||
|
echo "Running scheduler"
|
||||||
|
if [ "$NODE_ENV" = "production" ]; then
|
||||||
|
node ./dist/scheduler.js
|
||||||
|
else
|
||||||
|
npm run start:scheduler
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$NODE_ENV" = "production" ]; then
|
||||||
|
node ./dist/initializers/index.js
|
||||||
|
node ./dist/server.js
|
||||||
|
else
|
||||||
|
npm run initializers
|
||||||
|
npm run start
|
||||||
|
fi
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:24.15.0-alpine3.23
|
||||||
|
|
||||||
|
RUN npm i -g npm@11.14.1
|
||||||
|
|
||||||
|
WORKDIR /usr/src/api
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm clean-install
|
||||||
|
|
||||||
|
COPY bin/boot-app.sh ./bin/
|
||||||
|
RUN chmod +x ./bin/boot-app.sh
|
||||||
|
|
||||||
|
CMD ["/usr/src/api/bin/boot-app.sh"]
|
||||||
Generated
+8134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "alphane-api",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Alphane Backend",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc-alias",
|
||||||
|
"clean": "rm -rf ./dist",
|
||||||
|
"start": "ts-node-dev -r tsconfig-paths/register src/server.ts",
|
||||||
|
"start:scheduler": "ts-node-dev -r tsconfig-paths/register src/scheduler.ts",
|
||||||
|
"ts-node": "ts-node -r tsconfig-paths/register --logError",
|
||||||
|
"knex": "ts-node -r tsconfig-paths/register ./node_modules/.bin/knex --knexfile src/config.d/knexfile.ts",
|
||||||
|
"initializers": "ts-node -r tsconfig-paths/register --files src/initializers/index.ts",
|
||||||
|
"test": "vitest",
|
||||||
|
"lint": "eslint . --ext .js,.ts --ignore-path ../.gitignore",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"author": "Caleb Burke",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/audiowide": "^5.2.7",
|
||||||
|
"@sequelize/core": "^7.0.0-alpha.40",
|
||||||
|
"@sequelize/postgres": "^7.0.0-alpha.46",
|
||||||
|
"axios": "^1.7.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cron": "^4.4.0",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-form-data": "^3.0.1",
|
||||||
|
"express-jwt": "^8.4.1",
|
||||||
|
"geist": "^1.7.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jwks-rsa": "^3.1.0",
|
||||||
|
"knex": "^3.1.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"nodemailer": "^7.0.5",
|
||||||
|
"papaparse": "^5.4.1",
|
||||||
|
"redis": "^5.8.2",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"winston": "^3.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^9.9.0",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/express-form-data": "^2.0.5",
|
||||||
|
"@types/lodash": "^4.17.13",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node-schedule": "^2.1.5",
|
||||||
|
"@types/nodemailer": "^7.0.1",
|
||||||
|
"@types/papaparse": "^5.3.15",
|
||||||
|
"@types/sharp": "^0.31.1",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.40.0",
|
||||||
|
"@typescript-eslint/parser": "^8.40.0",
|
||||||
|
"axios-mock-adapter": "^2.1.0",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"fishery": "^2.2.2",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"supertest": "^7.1.4",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"tsc-alias": "^1.8.8",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import express, { type Request, type Response } from "express"
|
||||||
|
import cors from "cors"
|
||||||
|
import path from "path"
|
||||||
|
import helmet from "helmet"
|
||||||
|
import formData from "express-form-data"
|
||||||
|
|
||||||
|
import { AUTH0_DOMAIN, FRONTEND_URL } from "@/config"
|
||||||
|
import { requestLoggerMiddleware } from "@/middlewares"
|
||||||
|
import router from "@/router"
|
||||||
|
import enhancedQsDecoder from "@/utils/enhanced-qs-decoder"
|
||||||
|
|
||||||
|
export const app = express()
|
||||||
|
app.set("query parser", enhancedQsDecoder)
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "40mb" })) // for parsing application/json
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "40mb" })) // for parsing application/x-www-form-urlencoded
|
||||||
|
app.use(formData.parse({ autoClean: true, maxFieldsSize: 40 * 1024 * 1024 })) // for parsing multipart/form-data
|
||||||
|
app.use(formData.union()) // for parsing multipart/form-data
|
||||||
|
app.use(
|
||||||
|
helmet.contentSecurityPolicy({
|
||||||
|
directives: {
|
||||||
|
"default-src": ["'self'", FRONTEND_URL, AUTH0_DOMAIN],
|
||||||
|
"base-uri": ["'self'"],
|
||||||
|
"block-all-mixed-content": [],
|
||||||
|
"font-src": ["'self'", "https:", "data:"],
|
||||||
|
"frame-ancestors": ["'self'"],
|
||||||
|
"img-src": ["'self'", "data:", "https:"],
|
||||||
|
"object-src": ["'none'"],
|
||||||
|
"script-src": ["'self'", "'unsafe-eval'"],
|
||||||
|
"script-src-attr": ["'none'"],
|
||||||
|
"style-src": ["'self'", "https:", "'unsafe-inline'"],
|
||||||
|
"worker-src": ["'self'", "blob:"],
|
||||||
|
"frame-src": ["'self'", "blob:", "data:", FRONTEND_URL, AUTH0_DOMAIN],
|
||||||
|
"connect-src": ["'self'", FRONTEND_URL, AUTH0_DOMAIN],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// very basic CORS setup
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: FRONTEND_URL,
|
||||||
|
optionsSuccessStatus: 200,
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(requestLoggerMiddleware)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
// serves the static files generated by the front-end
|
||||||
|
app.use(express.static(path.join(__dirname, "web")))
|
||||||
|
|
||||||
|
// if no other routes match, just send the front-end
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
res.sendFile(path.join(__dirname, "web/index.html"))
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Knex } from "knex"
|
||||||
|
|
||||||
|
import dbMigrationClient from "@/db/db-migration-client"
|
||||||
|
|
||||||
|
// Hoists config from db client
|
||||||
|
const config: { [key: string]: Knex.Config } = {
|
||||||
|
development: {
|
||||||
|
...dbMigrationClient.client.config,
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
...dbMigrationClient.client.config,
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
...dbMigrationClient.client.config,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import path from "path"
|
||||||
|
import * as dotenv from "dotenv"
|
||||||
|
|
||||||
|
import { stripTrailingSlash } from "@/utils/strip-trailing-slash"
|
||||||
|
|
||||||
|
export const NODE_ENV = process.env.NODE_ENV || "development"
|
||||||
|
|
||||||
|
let dotEnvPath
|
||||||
|
switch (process.env.NODE_ENV) {
|
||||||
|
case "test":
|
||||||
|
dotEnvPath = path.resolve(__dirname, "../.env.test")
|
||||||
|
break
|
||||||
|
case "production":
|
||||||
|
dotEnvPath = path.resolve(__dirname, "../.env.production")
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
dotEnvPath = path.resolve(__dirname, "../.env.development")
|
||||||
|
}
|
||||||
|
|
||||||
|
dotenv.config({ path: dotEnvPath })
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
console.log("Loading env: ", dotEnvPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_PORT = process.env.API_PORT || "3000"
|
||||||
|
export const JOB_PORT = process.env.JOB_PORT || "3001"
|
||||||
|
|
||||||
|
export const FRONTEND_URL = process.env.FRONTEND_URL || ""
|
||||||
|
export const AUTH0_DOMAIN = stripTrailingSlash(process.env.VITE_AUTH0_DOMAIN || "")
|
||||||
|
export const AUTH0_AUDIENCE = process.env.VITE_AUTH0_AUDIENCE
|
||||||
|
export const AUTH0_REDIRECT = process.env.VITE_AUTH0_REDIRECT || process.env.FRONTEND_URL || ""
|
||||||
|
|
||||||
|
export const APPLICATION_NAME = process.env.VITE_APPLICATION_NAME || ""
|
||||||
|
|
||||||
|
export const DB_HOST = process.env.DB_HOST || ""
|
||||||
|
export const DB_USERNAME = process.env.DB_USERNAME || ""
|
||||||
|
export const DB_PASSWORD = process.env.DB_PASSWORD || ""
|
||||||
|
export const DB_DATABASE = process.env.DB_DATABASE || ""
|
||||||
|
export const DB_PORT = parseInt(process.env.DB_PORT || "1433")
|
||||||
|
export const DB_TRUST_SERVER_CERTIFICATE = process.env.DB_TRUST_SERVER_CERTIFICATE === "true"
|
||||||
|
|
||||||
|
export const REDIS_CONNECTION_URL = process.env.REDIS_CONNECTION_URL || ""
|
||||||
|
|
||||||
|
export const DB_HEALTH_CHECK_INTERVAL_SECONDS = parseInt(
|
||||||
|
process.env.DB_HEALTH_CHECK_INTERVAL_SECONDS || "5"
|
||||||
|
)
|
||||||
|
export const DB_HEALTH_CHECK_TIMEOUT_SECONDS = parseInt(
|
||||||
|
process.env.DB_HEALTH_CHECK_TIMEOUT_SECONDS || "10"
|
||||||
|
)
|
||||||
|
export const DB_HEALTH_CHECK_RETRIES = parseInt(process.env.DB_HEALTH_CHECK_RETRIES || "3")
|
||||||
|
export const DB_HEALTH_CHECK_START_PERIOD_SECONDS = parseInt(
|
||||||
|
process.env.DB_HEALTH_CHECK_START_PERIOD_SECONDS || "5"
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SEQUELIZE_LOGGING = process.env.SEQUELIZE_LOGGING === "true"
|
||||||
|
|
||||||
|
export const RELEASE_TAG = process.env.RELEASE_TAG || ""
|
||||||
|
export const GIT_COMMIT_HASH = process.env.GIT_COMMIT_HASH || ""
|
||||||
|
|
||||||
|
export const RUN_SCHEDULER = process.env.RUN_SCHEDULER || "false"
|
||||||
|
|
||||||
|
export const DEFAULT_LOG_LEVEL = process.env.DEFAULT_LOG_LEVEL || "debug"
|
||||||
|
|
||||||
|
// Internal Helpers
|
||||||
|
export const APP_ROOT_PATH = path.resolve(__dirname, "..")
|
||||||
|
export const SOURCE_ROOT_PATH =
|
||||||
|
NODE_ENV === "production" ? path.join(APP_ROOT_PATH, "dist") : path.join(APP_ROOT_PATH, "src")
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Controllers
|
||||||
|
|
||||||
|
These files map api routes to models, policies, services, and serializers.
|
||||||
|
See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
router.route("/api/users").post(UsersController.create)
|
||||||
|
```
|
||||||
|
|
||||||
|
maps the `/api/users` POST endpoint to the `UsersController#create` instance method.
|
||||||
|
|
||||||
|
Controllers are advantageous because they provide a suite of helper methods to access various request methods. .e.g. `currentUser`, or `params`. They also provide a location to perform policy checks.
|
||||||
|
|
||||||
|
Controllers should implement the BaseController, and provide instance methods.
|
||||||
|
The `BaseController` provides the magic that lets those methods map to an appropriate route.
|
||||||
|
|
||||||
|
## Namespacing
|
||||||
|
|
||||||
|
If you need an action that syncs a user with an external directory, a POST route `/api/users/:userId/directory-sync` is the best way to avoid future conflicts and refactors. To implement this you need to "namespace/modularize" the controller. Generally speaking, it is more flexible to keep all routes as CRUD actions, and nest controllers as needed, than it is to add custom routes to a given controller.
|
||||||
|
|
||||||
|
e.g. `Users.DirectorySyncController.create` is preferred to `UsersController#directorySync`. Once you start using non-CRUD actions, your controllers will quickly expand beyond human readability and comprehension. Opting to use PascalCase for namespaces as that is the best way to avoid conflicts with local variables.
|
||||||
|
|
||||||
|
This is how you would create a namespaced controller:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
api/
|
||||||
|
|-- src/
|
||||||
|
| |-- controllers/
|
||||||
|
| |-- users/
|
||||||
|
| |-- directory-sync-controller.ts
|
||||||
|
| |-- index.ts
|
||||||
|
| |-- users-controller.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/src/controllers/users/users-controller.ts
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseController from "@/base-controller"
|
||||||
|
|
||||||
|
export class UsersController extends BaseController<User> {
|
||||||
|
static async create() {
|
||||||
|
// Perform user creation
|
||||||
|
// Perform policy check
|
||||||
|
// Call appropriate service
|
||||||
|
// Perform serialization if needed
|
||||||
|
// Return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/src/controllers/users/directory-sync-controller.ts
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseController from "@/base-controller"
|
||||||
|
|
||||||
|
export class DirectorySyncController extends BaseController<User> {
|
||||||
|
static async create() {
|
||||||
|
// Perform user lookup
|
||||||
|
// Perform policy check
|
||||||
|
// Call appropriate service
|
||||||
|
// Perform serialization if needed
|
||||||
|
// Return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/src/controllers/users/index.ts
|
||||||
|
export { DirectorySyncController } from "./directory-sync-controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/src/controllers/index.ts
|
||||||
|
import * as Users from "./users"
|
||||||
|
|
||||||
|
import { UsersController } from "./users-controller"
|
||||||
|
|
||||||
|
export { Users, UsersController }
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// api/src/routes.ts
|
||||||
|
import { Router } from "express"
|
||||||
|
|
||||||
|
import { Users } from "@/controllers"
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.route("/api/users").get(UsersController.index).post(UsersController.create)
|
||||||
|
router
|
||||||
|
.route("/api/users/:userId")
|
||||||
|
.get(UsersController.show)
|
||||||
|
.put(UsersController.update)
|
||||||
|
.delete(UsersController.destroy)
|
||||||
|
|
||||||
|
router.route("/api/users/:userId/directory-sync").post(Users.DirectorySyncController.create)
|
||||||
|
```
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { NextFunction, Request, Response } from "express"
|
||||||
|
import { Attributes, Model, Order, WhereOptions } from "@sequelize/core"
|
||||||
|
import { dropRight, isEmpty, isNil, uniqBy } from "lodash"
|
||||||
|
|
||||||
|
import User from "@/models/user"
|
||||||
|
import { type BaseScopeOptions } from "@/policies"
|
||||||
|
|
||||||
|
export type Actions = "index" | "show" | "new" | "edit" | "create" | "update" | "destroy"
|
||||||
|
|
||||||
|
type ControllerRequest = Request & {
|
||||||
|
currentUser: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep in sync with web/src/api/base-api.ts */
|
||||||
|
export type ModelOrder = Order &
|
||||||
|
(
|
||||||
|
| [string, string]
|
||||||
|
| [string, string, string]
|
||||||
|
| [string, string, string, string]
|
||||||
|
| [string, string, string, string, string]
|
||||||
|
| [string, string, string, string, string, string]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep in sync with web/src/api/base-api.ts
|
||||||
|
const MAX_PER_PAGE = 1000
|
||||||
|
const MAX_PER_PAGE_EQUIVALENT = -1
|
||||||
|
const DEFAULT_PER_PAGE = 10
|
||||||
|
|
||||||
|
// See https://guides.rubyonrails.org/routing.html#crud-verbs-and-actions
|
||||||
|
export class BaseController<TModel extends Model = never> {
|
||||||
|
protected request: ControllerRequest
|
||||||
|
protected response: Response
|
||||||
|
protected next: NextFunction
|
||||||
|
|
||||||
|
cacheIndex = false
|
||||||
|
cacheShow = false
|
||||||
|
cacheDuration = 0
|
||||||
|
cachePrefix = ""
|
||||||
|
cacheDependents = new Array<string>()
|
||||||
|
|
||||||
|
constructor(req: Request, res: Response, next: NextFunction) {
|
||||||
|
// Assumes authorization has occured first in
|
||||||
|
// api/src/middlewares/jwt-middleware.ts and api/src/middlewares/authorization-middleware.ts
|
||||||
|
// At some future point it would make sense to do all that logic as
|
||||||
|
// controller actions to avoid the need for hack
|
||||||
|
this.request = req as ControllerRequest
|
||||||
|
this.response = res as Response
|
||||||
|
this.next = next
|
||||||
|
}
|
||||||
|
|
||||||
|
static get index() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controllerInstance = new this(req, res, next)
|
||||||
|
return controllerInstance.index().catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage app.post("/api/users", UsersController.create)
|
||||||
|
// maps /api/users to UsersController#create()
|
||||||
|
static get create() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controllerInstance = new this(req, res, next)
|
||||||
|
return controllerInstance.create().catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get show() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controllerInstance = new this(req, res, next)
|
||||||
|
return controllerInstance.show().catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get update() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controllerInstance = new this(req, res, next)
|
||||||
|
return controllerInstance.update().catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get destroy() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const controllerInstance = new this(req, res, next)
|
||||||
|
return controllerInstance.destroy().catch(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index(): Promise<unknown> {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): Promise<unknown> {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): Promise<unknown> {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): Promise<unknown> {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): Promise<unknown> {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal helpers
|
||||||
|
|
||||||
|
// This should have been loaded in the authorization middleware
|
||||||
|
// Currently assuming that authorization happens before this controller gets called.
|
||||||
|
// Child controllers that are on an non-authorizable route should override this method
|
||||||
|
// and return undefined
|
||||||
|
get currentUser(): User {
|
||||||
|
return this.request.currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
get params() {
|
||||||
|
return this.request.params
|
||||||
|
}
|
||||||
|
|
||||||
|
get query() {
|
||||||
|
return this.request.query
|
||||||
|
}
|
||||||
|
|
||||||
|
get pagination() {
|
||||||
|
const page = parseInt(this.query.page?.toString() || "") || 1
|
||||||
|
const perPage = parseInt(this.query.perPage?.toString() || "") || DEFAULT_PER_PAGE
|
||||||
|
const limit = this.determineLimit(perPage)
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWhere<TModelOverride extends Model = TModel>(
|
||||||
|
overridableOptions: WhereOptions<Attributes<TModelOverride>> = {},
|
||||||
|
nonOverridableOptions: WhereOptions<Attributes<TModelOverride>> = {}
|
||||||
|
): WhereOptions<Attributes<TModelOverride>> {
|
||||||
|
// TODO: consider if we should add parsing of Sequelize [Op.is] and [Op.not] here
|
||||||
|
// or in the api/src/utils/enhanced-qs-decoder.ts function
|
||||||
|
const queryWhere = this.query.where as WhereOptions<Attributes<TModelOverride>>
|
||||||
|
return {
|
||||||
|
...overridableOptions,
|
||||||
|
...queryWhere,
|
||||||
|
...nonOverridableOptions,
|
||||||
|
} as WhereOptions<Attributes<TModelOverride>>
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFilterScopes<FilterOptions extends Record<string, unknown>>(
|
||||||
|
initialScopes: BaseScopeOptions[] = [],
|
||||||
|
filtersOverride?: FilterOptions
|
||||||
|
): BaseScopeOptions[] {
|
||||||
|
const filters = filtersOverride ?? (this.query.filters as FilterOptions)
|
||||||
|
const scopes = initialScopes
|
||||||
|
if (!isEmpty(filters)) {
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
scopes.push({ method: [key, value] })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return scopes
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOrder(
|
||||||
|
overridableOrder: ModelOrder[] = [],
|
||||||
|
nonOverridableOrder: ModelOrder[] = []
|
||||||
|
): ModelOrder[] | undefined {
|
||||||
|
const orderQuery = this.query.order as unknown as ModelOrder[] | undefined
|
||||||
|
|
||||||
|
if (isNil(orderQuery)) {
|
||||||
|
return [...nonOverridableOrder, ...overridableOrder]
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = [...nonOverridableOrder, ...orderQuery, ...overridableOrder]
|
||||||
|
const uniqueOrder = uniqBy(order, (order) => {
|
||||||
|
const orderExcludingDirection = dropRight(order)
|
||||||
|
return orderExcludingDirection.join(".").toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
return uniqueOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineLimit(perPage: number) {
|
||||||
|
if (perPage === MAX_PER_PAGE_EQUIVALENT) {
|
||||||
|
return MAX_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(perPage, MAX_PER_PAGE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseController
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { User } from "@/models"
|
||||||
|
import { UsersPolicy } from "@/policies"
|
||||||
|
import { ShowSerializer } from "@/serializers/current-user"
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
export class CurrentUserController extends BaseController {
|
||||||
|
async show() {
|
||||||
|
try {
|
||||||
|
const user = this.currentUser
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.show()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to view the current user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedUser = ShowSerializer.perform(user)
|
||||||
|
return this.response.json({ user: serializedUser, policy })
|
||||||
|
} catch (error) {
|
||||||
|
return this.response.status(400).json({
|
||||||
|
message: `Error fetching current user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(user: User) {
|
||||||
|
return new UsersPolicy(this.currentUser, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CurrentUserController
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Controllers
|
||||||
|
export { CurrentUserController } from "./current-user-controller"
|
||||||
|
export { UsersController } from "./users-controller"
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { UsersPolicy } from "@/policies"
|
||||||
|
import { CreateService, DestroyService, UpdateService } from "@/services/users"
|
||||||
|
import { IndexSerializer, ShowSerializer } from "@/serializers/users"
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
export class UsersController extends BaseController<User> {
|
||||||
|
async index() {
|
||||||
|
try {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const scopes = this.buildFilterScopes()
|
||||||
|
const scopedUsers = UsersPolicy.applyScope(scopes, this.currentUser)
|
||||||
|
|
||||||
|
const totalCount = await scopedUsers.count({ where })
|
||||||
|
const users = await scopedUsers.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
order: this.buildOrder(),
|
||||||
|
})
|
||||||
|
const serializedUsers = IndexSerializer.perform(users)
|
||||||
|
return this.response.json({
|
||||||
|
users: serializedUsers,
|
||||||
|
totalCount,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching users" + error)
|
||||||
|
return this.response.status(400).json({
|
||||||
|
message: `Error fetching users: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
try {
|
||||||
|
const user = await this.loadUser()
|
||||||
|
if (isNil(user)) {
|
||||||
|
return this.response.status(404).json({
|
||||||
|
message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.show()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to view this user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const serializedUser = ShowSerializer.perform(user)
|
||||||
|
return this.response.json({ user: serializedUser, policy })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching user" + error)
|
||||||
|
return this.response.status(400).json({
|
||||||
|
message: `Error fetching user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
try {
|
||||||
|
const policy = this.buildPolicy()
|
||||||
|
if (!policy.create()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to create users",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
|
||||||
|
const user = await CreateService.perform(permittedAttributes)
|
||||||
|
const serializedUser = ShowSerializer.perform(user)
|
||||||
|
return this.response.status(201).json({ user: serializedUser })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error creating user" + error)
|
||||||
|
return this.response.status(422).json({
|
||||||
|
message: `Error creating user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
try {
|
||||||
|
const user = await this.loadUser()
|
||||||
|
if (isNil(user)) {
|
||||||
|
return this.response.status(404).json({
|
||||||
|
message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.update()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to update this user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributes(this.request.body)
|
||||||
|
const updatedUser = await UpdateService.perform(user, permittedAttributes)
|
||||||
|
const serializedUser = ShowSerializer.perform(updatedUser)
|
||||||
|
return this.response.json({ user: serializedUser })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating user" + error)
|
||||||
|
return this.response.status(422).json({
|
||||||
|
message: `Error updating user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
try {
|
||||||
|
const user = await this.loadUser()
|
||||||
|
if (isNil(user)) {
|
||||||
|
return this.response.status(404).json({
|
||||||
|
message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.destroy()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to delete this user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await DestroyService.perform(user)
|
||||||
|
return this.response.status(204).send()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error deleting user" + error)
|
||||||
|
return this.response.status(422).json({
|
||||||
|
message: `Error deleting user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadUser() {
|
||||||
|
return User.findByPk(this.params.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(user: User = User.build()) {
|
||||||
|
return new UsersPolicy(this.currentUser, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersController
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { REDIS_CONNECTION_URL } from "@/config"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
import { RedisClientType, createClient } from "@redis/client"
|
||||||
|
import { ScanOptions } from "@redis/client/dist/lib/commands/SCAN"
|
||||||
|
|
||||||
|
class CacheClient {
|
||||||
|
protected client: RedisClientType
|
||||||
|
protected failures: number = 0
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
logger.info(`INIT CACHE: ${REDIS_CONNECTION_URL}`)
|
||||||
|
this.client = createClient({ url: REDIS_CONNECTION_URL })
|
||||||
|
|
||||||
|
this.client.on("error", (err) => {
|
||||||
|
this.onError(err)
|
||||||
|
})
|
||||||
|
this.client.on("connect", async () => {
|
||||||
|
this.failures = 0
|
||||||
|
await this.setValueNoExpire("TESTING", 123)
|
||||||
|
logger.info("Redis Client Connect")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
onError(err: any) {
|
||||||
|
if (this.failures < 5) {
|
||||||
|
this.failures++
|
||||||
|
logger.error(`Redis Connection Error ${this.failures}: ${err.message}`)
|
||||||
|
} else if (this.failures == 5) {
|
||||||
|
this.failures++
|
||||||
|
logger.error("Giving up on Redis")
|
||||||
|
} else {
|
||||||
|
logger.error(`Redis Generic Error: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient() {
|
||||||
|
if (!this.client.isOpen) await this.client.connect()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
async setValue(key: string, value: any, expireSeconds = 90) {
|
||||||
|
if (expireSeconds <= 0) this.setValueNoExpire(key, value)
|
||||||
|
else this.client.set(key, value, { EX: expireSeconds })
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
async setValueNoExpire(key: string, value: any) {
|
||||||
|
this.client.set(key, value)
|
||||||
|
}
|
||||||
|
async getValue(key: string) {
|
||||||
|
return this.client.get(key)
|
||||||
|
}
|
||||||
|
async deleteValue(key: string) {
|
||||||
|
return this.client.del(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteValuesByPattern(pattern: string) {
|
||||||
|
const scanCommand = { MATCH: `${pattern}*` } as ScanOptions
|
||||||
|
let cursor = "0"
|
||||||
|
|
||||||
|
do {
|
||||||
|
const reply = await this.client.scan(cursor, scanCommand)
|
||||||
|
cursor = reply.cursor
|
||||||
|
const keys = reply.keys
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await this.client.del(keys)
|
||||||
|
}
|
||||||
|
} while (cursor !== "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
async getKeysByPattern(pattern: string): Promise<string[]> {
|
||||||
|
const scanCommand = { MATCH: `${pattern}*` } as ScanOptions
|
||||||
|
let cursor = "0"
|
||||||
|
let matches = new Array<string>()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const reply = await this.client.scan(cursor, scanCommand)
|
||||||
|
cursor = reply.cursor
|
||||||
|
const keys = reply.keys
|
||||||
|
|
||||||
|
if (keys.length > 0) {
|
||||||
|
matches = matches.concat(keys)
|
||||||
|
}
|
||||||
|
} while (cursor !== "0")
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new CacheClient()
|
||||||
|
|
||||||
|
export default cache
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# DB Data
|
||||||
|
|
||||||
|
This folder contains SQL scripts or JSON files that contain data for seeding the database.
|
||||||
|
Most of these things will be converted into seeds and factories, but they live here in the meantime.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Sequelize, Options } from "@sequelize/core"
|
||||||
|
import { PostgresDialect } from "@sequelize/postgres"
|
||||||
|
import { isEmpty, isNil } from "lodash"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DB_DATABASE,
|
||||||
|
DB_HOST,
|
||||||
|
DB_PASSWORD,
|
||||||
|
DB_PORT,
|
||||||
|
DB_USERNAME,
|
||||||
|
NODE_ENV,
|
||||||
|
SEQUELIZE_LOGGING,
|
||||||
|
} from "@/config"
|
||||||
|
import compactSql from "@/utils/compact-sql"
|
||||||
|
|
||||||
|
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
|
||||||
|
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
|
||||||
|
if (isEmpty(DB_PASSWORD)) throw new Error("database password is unset.")
|
||||||
|
if (isEmpty(DB_HOST)) throw new Error("database host is unset.")
|
||||||
|
if (isNil(DB_PORT) || isNaN(DB_PORT)) throw new Error("database port is unset.")
|
||||||
|
|
||||||
|
function sqlLogger(query: string) {
|
||||||
|
console.log(compactSql(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://sequelize.org/docs/v7/databases/postgres/
|
||||||
|
export const SEQUELIZE_CONFIG: Options<PostgresDialect> = {
|
||||||
|
dialect: PostgresDialect,
|
||||||
|
database: DB_DATABASE,
|
||||||
|
user: DB_USERNAME,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
host: DB_HOST,
|
||||||
|
port: DB_PORT,
|
||||||
|
ssl: NODE_ENV !== "production" ? false : { rejectUnauthorized: false },
|
||||||
|
schema: "public", // default - explicit for clarity
|
||||||
|
logging: SEQUELIZE_LOGGING ? sqlLogger : false,
|
||||||
|
pool: {
|
||||||
|
max: 20,
|
||||||
|
min: 2,
|
||||||
|
acquire: 60_000,
|
||||||
|
idle: 10_000,
|
||||||
|
evict: 10_000,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
underscored: true,
|
||||||
|
timestamps: true, // default - explicit for clarity.
|
||||||
|
paranoid: true, // adds deleted_at column
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Sequelize(SEQUELIZE_CONFIG)
|
||||||
|
|
||||||
|
export default db
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import path from "path"
|
||||||
|
|
||||||
|
import knex, { Knex } from "knex"
|
||||||
|
import { isEmpty, isNil, merge } from "lodash"
|
||||||
|
|
||||||
|
import { DB_DATABASE, DB_HOST, DB_PASSWORD, DB_PORT, DB_USERNAME, NODE_ENV } from "@/config"
|
||||||
|
|
||||||
|
if (isEmpty(DB_DATABASE)) throw new Error("database name is unset.")
|
||||||
|
if (isEmpty(DB_USERNAME)) throw new Error("database username is unset.")
|
||||||
|
if (isEmpty(DB_PASSWORD)) throw new Error("database password is unset.")
|
||||||
|
if (isEmpty(DB_HOST)) throw new Error("database host is unset.")
|
||||||
|
if (isNil(DB_PORT) || isNaN(DB_PORT)) throw new Error("database port is unset.")
|
||||||
|
|
||||||
|
export function buildKnexConfig(options?: Knex.Config): Knex.Config {
|
||||||
|
return merge(
|
||||||
|
{
|
||||||
|
client: "pg",
|
||||||
|
connection: {
|
||||||
|
host: DB_HOST,
|
||||||
|
user: DB_USERNAME,
|
||||||
|
password: DB_PASSWORD,
|
||||||
|
database: DB_DATABASE,
|
||||||
|
port: DB_PORT,
|
||||||
|
ssl:
|
||||||
|
NODE_ENV !== "production"
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
require: true, // Enforce SSL
|
||||||
|
rejectUnauthorized: false, // Disable certificate verification (common for Azure)
|
||||||
|
},
|
||||||
|
/* options: {
|
||||||
|
encrypt: true,
|
||||||
|
trustServerCertificate: DB_TRUST_SERVER_CERTIFICATE,
|
||||||
|
}, */
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
directory: path.resolve(__dirname, "./migrations"),
|
||||||
|
extension: "ts",
|
||||||
|
stub: path.resolve(__dirname, "./templates/sample-migration.ts"),
|
||||||
|
},
|
||||||
|
seeds: {
|
||||||
|
directory: path.resolve(__dirname, `./seeds/${NODE_ENV}`),
|
||||||
|
extension: "ts",
|
||||||
|
stub: path.resolve(__dirname, "./templates/sample-seed.ts"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = buildKnexConfig()
|
||||||
|
const dbMigrationClient = knex(config)
|
||||||
|
|
||||||
|
export default dbMigrationClient
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Knex } from "knex"
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.createTable("users", function (table) {
|
||||||
|
table.increments("id").notNullable().primary()
|
||||||
|
table.string("email", 100).notNullable()
|
||||||
|
table.string("auth0_subject", 100).notNullable()
|
||||||
|
table.string("first_name", 100).notNullable()
|
||||||
|
table.string("last_name", 100).notNullable()
|
||||||
|
table.string("display_name", 200).notNullable()
|
||||||
|
table.string("roles", 255).notNullable()
|
||||||
|
|
||||||
|
table
|
||||||
|
.specificType("created_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table
|
||||||
|
.specificType("updated_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
.notNullable()
|
||||||
|
.defaultTo(knex.raw("CURRENT_TIMESTAMP(0)"))
|
||||||
|
table.specificType("deleted_at", "TIMESTAMP WITH TIME ZONE")
|
||||||
|
|
||||||
|
table.unique(["email"], {
|
||||||
|
indexName: "users_email_unique",
|
||||||
|
predicate: knex.whereNull("deleted_at"),
|
||||||
|
})
|
||||||
|
table.unique(["auth0_subject"], {
|
||||||
|
indexName: "users_auth0_subject_unique",
|
||||||
|
predicate: knex.whereNull("deleted_at"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTable("users")
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import express, { Request, Response } from "express"
|
||||||
|
import { join } from "path"
|
||||||
|
|
||||||
|
import { NODE_ENV } from "@/config"
|
||||||
|
import dbMigrationClient from "@/db/db-migration-client"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
export class Migrator {
|
||||||
|
readonly migrationRouter
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.migrationRouter = express.Router()
|
||||||
|
|
||||||
|
this.migrationRouter.get("/", async (_req: Request, res: Response) => {
|
||||||
|
return res.json({ data: await this.listMigrations() })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.migrationRouter.get("/up", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await this.migrateUp()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err)
|
||||||
|
}
|
||||||
|
return res.json({ data: await migrator.listMigrations() })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.migrationRouter.get("/down", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await this.migrateDown()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err)
|
||||||
|
}
|
||||||
|
return res.json({ data: await this.listMigrations() })
|
||||||
|
})
|
||||||
|
|
||||||
|
this.migrationRouter.get("/seed/:environment", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await this.seedUp(req.params.environment)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(err)
|
||||||
|
}
|
||||||
|
return res.json({ data: "Seeding" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listMigrations() {
|
||||||
|
return dbMigrationClient.migrate.list({ directory: join(__dirname, "migrations") })
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateUp() {
|
||||||
|
logger.warn("-------- MIGRATE UP ---------")
|
||||||
|
return dbMigrationClient.migrate.up({ directory: join(__dirname, "migrations") })
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateDown() {
|
||||||
|
logger.warn("-------- MIGRATE DOWN ---------")
|
||||||
|
return dbMigrationClient.migrate.down({ directory: join(__dirname, "migrations") })
|
||||||
|
}
|
||||||
|
|
||||||
|
async migrateLatest() {
|
||||||
|
logger.warn("-------- MIGRATE LATEST ---------")
|
||||||
|
return dbMigrationClient.migrate.latest({ directory: join(__dirname, "migrations") })
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedUp(environment?: string) {
|
||||||
|
logger.warn("-------- SEED UP ---------")
|
||||||
|
return dbMigrationClient.seed.run({
|
||||||
|
directory: join(__dirname, "seeds", environment || NODE_ENV),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const migrator = new Migrator()
|
||||||
|
|
||||||
|
export default migrator
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { CreationAttributes } from "@sequelize/core"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { CreateService } from "@/services/users"
|
||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
export async function seed(): Promise<void> {
|
||||||
|
const systemUserAttributes: CreationAttributes<User> = {
|
||||||
|
email: "system.user@alphane.com",
|
||||||
|
auth0Subject: "NO_LOGIN_system.user@alphane.com",
|
||||||
|
firstName: "System",
|
||||||
|
lastName: "User",
|
||||||
|
displayName: "System User",
|
||||||
|
roles: [User.Roles.SYSTEM_ADMIN],
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: systemUserAttributes.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNil(user)) {
|
||||||
|
const createdUser = await CreateService.perform(systemUserAttributes)
|
||||||
|
logger.debug("System User created:", createdUser.dataValues)
|
||||||
|
} else {
|
||||||
|
await user.update(systemUserAttributes)
|
||||||
|
logger.debug("System User updated:", user.dataValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { CreationAttributes } from "@sequelize/core"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { CreateService } from "@/services/users"
|
||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
export async function seed(): Promise<void> {
|
||||||
|
const systemUserAttributes: CreationAttributes<User> = {
|
||||||
|
email: "system.user@alphane.com",
|
||||||
|
auth0Subject: "NO_LOGIN_system.user@alphane.com",
|
||||||
|
firstName: "System",
|
||||||
|
lastName: "User",
|
||||||
|
displayName: "System User",
|
||||||
|
roles: [User.Roles.SYSTEM_ADMIN],
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: systemUserAttributes.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNil(user)) {
|
||||||
|
const createdUser = await CreateService.perform(systemUserAttributes)
|
||||||
|
logger.debug("System User created:", createdUser.dataValues)
|
||||||
|
} else {
|
||||||
|
await user.update(systemUserAttributes)
|
||||||
|
logger.debug("System User updated:", user.dataValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { CreationAttributes } from "@sequelize/core"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { CreateService } from "@/services/users"
|
||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
export async function seed(): Promise<void> {
|
||||||
|
const systemUserAttributes: CreationAttributes<User> = {
|
||||||
|
email: "system.user@alphane.com",
|
||||||
|
auth0Subject: "NO_LOGIN_system.user@alphane.com",
|
||||||
|
firstName: "System",
|
||||||
|
lastName: "User",
|
||||||
|
displayName: "System User",
|
||||||
|
roles: [User.Roles.SYSTEM_ADMIN],
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: systemUserAttributes.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNil(user)) {
|
||||||
|
const createdUser = await CreateService.perform(systemUserAttributes)
|
||||||
|
logger.debug("System User created:", createdUser.dataValues)
|
||||||
|
} else {
|
||||||
|
await user.update(systemUserAttributes)
|
||||||
|
logger.debug("System User updated:", user.dataValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Knex } from "knex"
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
throw new Error("Not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
throw new Error("Not implemented")
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Knex } from "knex"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
export async function seed(_knex: Knex): Promise<void> {
|
||||||
|
const usersAttributes = [
|
||||||
|
{
|
||||||
|
email: "system.user@richter-guardian.com",
|
||||||
|
auth0Subject: "system.user@richter-guardian.com",
|
||||||
|
firstName: "System",
|
||||||
|
lastName: "User",
|
||||||
|
displayName: "System User",
|
||||||
|
roles: [User.Roles.SYSTEM_ADMIN],
|
||||||
|
title: "System User",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for (const attributes of usersAttributes) {
|
||||||
|
let user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: attributes.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (isNil(user)) {
|
||||||
|
user = await User.create(attributes)
|
||||||
|
logger.debug("User created:", user.dataValues)
|
||||||
|
} else {
|
||||||
|
await user.update(attributes)
|
||||||
|
logger.debug("User updated:", user.dataValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import dbMigrationClient from "@/db/db-migration-client"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
export async function isValidConnection() {
|
||||||
|
try {
|
||||||
|
await dbMigrationClient.raw("SELECT GETDATE()")
|
||||||
|
logger.info("Connection has been established successfully.")
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Unable to connect to the database: " + error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
isValidConnection()
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Knex } from "knex"
|
||||||
|
import { QueryTypes, QueryOptionsWithType } from "@sequelize/core"
|
||||||
|
|
||||||
|
import db from "@/db/db-client"
|
||||||
|
|
||||||
|
type QueryOptions = Omit<QueryOptionsWithType<QueryTypes.SELECT>, "bind" | "type">
|
||||||
|
|
||||||
|
// TODO: fix types to show that it might return null
|
||||||
|
export async function knexQueryToSequelizeSelect<T extends object>(
|
||||||
|
knexQuery: Knex.QueryBuilder,
|
||||||
|
options: QueryOptions = {}
|
||||||
|
) {
|
||||||
|
const { sql: knexSql, bindings } = knexQuery.toSQL().toNative()
|
||||||
|
const { sql: sequelizeSql, bind } = knexSqlNativeToSequelizeQueryWithBind({
|
||||||
|
sql: knexSql,
|
||||||
|
bindings,
|
||||||
|
})
|
||||||
|
return db.query<T>(sequelizeSql, {
|
||||||
|
...options,
|
||||||
|
bind,
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note siganture is chosen so you can pass knexQuery.toSQL().toNative() directly
|
||||||
|
*
|
||||||
|
* Currently only tested with MSSQL dialect
|
||||||
|
*
|
||||||
|
* @param sqlWithKnexBindings knexQuery.toSQL().toNative().sql
|
||||||
|
* @param bindings knexQuery.toSQL().toNative().bindings
|
||||||
|
* @returns { sql: string, bind: unknown[] } in Sequelize format
|
||||||
|
*/
|
||||||
|
export function knexSqlNativeToSequelizeQueryWithBind({
|
||||||
|
sql: sqlWithKnexBindings,
|
||||||
|
bindings,
|
||||||
|
}: {
|
||||||
|
sql: string
|
||||||
|
bindings: readonly unknown[]
|
||||||
|
}): { sql: string; bind: unknown[] } {
|
||||||
|
let sqlWithSequelizeBindings = sqlWithKnexBindings
|
||||||
|
// converts "@p0" to "$1", "@p1" to "$2", etc.
|
||||||
|
bindings.forEach((_, i) => {
|
||||||
|
const pattern = new RegExp(`@p${i}\\b`, "g")
|
||||||
|
sqlWithSequelizeBindings = sqlWithSequelizeBindings.replace(pattern, `$${i + 1}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutableBindings = [...bindings]
|
||||||
|
|
||||||
|
return {
|
||||||
|
sql: sqlWithSequelizeBindings,
|
||||||
|
bind: mutableBindings,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
export function safeJsonParse(values: string): any[] {
|
||||||
|
try {
|
||||||
|
const lines = JSON.parse(values)
|
||||||
|
if (Array.isArray(lines)) {
|
||||||
|
return lines
|
||||||
|
} else {
|
||||||
|
logger.error("Parsed value is not an array.")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error parsing JSON: ${error}`, { error })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default safeJsonParse
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import knex, { type Knex } from "knex"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DB_HEALTH_CHECK_INTERVAL_SECONDS,
|
||||||
|
DB_HEALTH_CHECK_RETRIES,
|
||||||
|
DB_HEALTH_CHECK_START_PERIOD_SECONDS,
|
||||||
|
DB_HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||||
|
} from "@/config"
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import sleep from "@/utils/sleep"
|
||||||
|
import {
|
||||||
|
isCredentialFailure,
|
||||||
|
isNetworkFailure,
|
||||||
|
isSocketFailure,
|
||||||
|
isMissingDatabaseFailure,
|
||||||
|
} from "@/utils/db-error-helpers"
|
||||||
|
import { buildKnexConfig } from "@/db/db-migration-client"
|
||||||
|
|
||||||
|
function checkHealth(dbMigrationClient: Knex, timeoutSeconds: number) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("Connection timeout")), timeoutSeconds * 1000)
|
||||||
|
dbMigrationClient
|
||||||
|
.raw("SELECT 1")
|
||||||
|
.then(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(null)
|
||||||
|
})
|
||||||
|
.catch(reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForDatabase({
|
||||||
|
intervalSeconds = DB_HEALTH_CHECK_INTERVAL_SECONDS,
|
||||||
|
timeoutSeconds = DB_HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||||
|
retries = DB_HEALTH_CHECK_RETRIES,
|
||||||
|
startPeriodSeconds = DB_HEALTH_CHECK_START_PERIOD_SECONDS,
|
||||||
|
}: {
|
||||||
|
intervalSeconds?: number
|
||||||
|
timeoutSeconds?: number
|
||||||
|
retries?: number
|
||||||
|
startPeriodSeconds?: number
|
||||||
|
} = {}): Promise<void> {
|
||||||
|
await sleep(startPeriodSeconds)
|
||||||
|
|
||||||
|
logger.info("Attempting direct to database connection...")
|
||||||
|
const databaseConfig = buildKnexConfig()
|
||||||
|
|
||||||
|
let dbMigrationClient = knex(databaseConfig)
|
||||||
|
let isDatabaseSocketReady = false
|
||||||
|
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
await checkHealth(dbMigrationClient, timeoutSeconds)
|
||||||
|
logger.info("Database connection successful.")
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (isSocketFailure(error)) {
|
||||||
|
logger.info(`Database socket is not ready, retrying... ${error}`, { error })
|
||||||
|
await sleep(intervalSeconds)
|
||||||
|
} else if (isNetworkFailure(error)) {
|
||||||
|
logger.info(`Network error, retrying... ${error}`, { error })
|
||||||
|
await sleep(intervalSeconds)
|
||||||
|
} else if (isCredentialFailure(error)) {
|
||||||
|
if (isDatabaseSocketReady) {
|
||||||
|
logger.error(`Database connection failed due to invalid credentials: ${error}`, { error })
|
||||||
|
throw error
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Falling back to database server-level connection (database might not exist)..."
|
||||||
|
)
|
||||||
|
const serverLevelConfig = buildKnexConfig({ connection: { database: "" } })
|
||||||
|
dbMigrationClient = knex(serverLevelConfig)
|
||||||
|
i -= 1
|
||||||
|
isDatabaseSocketReady = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else if (isMissingDatabaseFailure(error)) {
|
||||||
|
if (isDatabaseSocketReady) {
|
||||||
|
logger.error(`Database connection failed because database does not exist): ${error}`, {
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"Falling back to default postgres connection (after database does not exist failure)..."
|
||||||
|
)
|
||||||
|
const serverLevelConfig = buildKnexConfig({ connection: { database: "postgres" } })
|
||||||
|
dbMigrationClient = knex(serverLevelConfig)
|
||||||
|
i -= 1
|
||||||
|
isDatabaseSocketReady = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(`Unknown database connection error: ${error}`, { error })
|
||||||
|
//throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to connect to the database due to timeout.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default waitForDatabase
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import knex, { type Knex } from "knex"
|
||||||
|
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
import { isCredentialFailure, isMissingDatabaseFailure } from "@/utils/db-error-helpers"
|
||||||
|
import { buildKnexConfig } from "@/db/db-migration-client"
|
||||||
|
import { DB_DATABASE } from "@/config"
|
||||||
|
|
||||||
|
async function databaseExists(dbMigrationClient: Knex, databaseName: string): Promise<boolean> {
|
||||||
|
const result = await dbMigrationClient.raw("SELECT 1 FROM pg_database WHERE datname = ?", [
|
||||||
|
databaseName,
|
||||||
|
])
|
||||||
|
|
||||||
|
return result.rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDatabase(): Promise<true> {
|
||||||
|
logger.info("Attempting direct to database connection to determine if database exists...")
|
||||||
|
const databaseConfig = buildKnexConfig()
|
||||||
|
let dbMigrationClient = knex(databaseConfig)
|
||||||
|
let isCredentialFailureError = false
|
||||||
|
let isMissingDatabaseError = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (await databaseExists(dbMigrationClient, DB_DATABASE)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isCredentialFailure(error)) {
|
||||||
|
isCredentialFailureError = true
|
||||||
|
logger.info("Database connection failed due to invalid credential, retrying...")
|
||||||
|
}
|
||||||
|
if (isMissingDatabaseFailure(error)) {
|
||||||
|
isMissingDatabaseError = true
|
||||||
|
logger.info("Database connection failed due missing default database, retrying...")
|
||||||
|
} else {
|
||||||
|
logger.error(`Unknown connection failure, could not determine if database exists: ${error}`, {
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCredentialFailureError || isMissingDatabaseError) {
|
||||||
|
logger.info("Attempting server-level connection to determine if database exists...")
|
||||||
|
const serverLevelConfig = buildKnexConfig({ connection: { database: "" } })
|
||||||
|
dbMigrationClient = knex(serverLevelConfig)
|
||||||
|
try {
|
||||||
|
if (await databaseExists(dbMigrationClient, DB_DATABASE)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Could not determine if database exists database with server-level connection: ${error}`,
|
||||||
|
{ error }
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Database ${DB_DATABASE} does not exist: creating...`)
|
||||||
|
try {
|
||||||
|
await dbMigrationClient.raw(`CREATE DATABASE ${DB_DATABASE}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create database: ${error}`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createDatabase
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import dbMigrationClient from "@/db/db-migration-client"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
type MigrationInfo = {
|
||||||
|
file: string
|
||||||
|
directory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations(): Promise<void> {
|
||||||
|
const [_completedMigrations, pendingMigrations]: [MigrationInfo[], MigrationInfo[]] =
|
||||||
|
await dbMigrationClient.migrate.list()
|
||||||
|
|
||||||
|
if (pendingMigrations.length === 0) {
|
||||||
|
logger.info("No pending migrations.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { file, directory } of pendingMigrations) {
|
||||||
|
logger.info(`Running migration: ${directory}/${file}`)
|
||||||
|
try {
|
||||||
|
await dbMigrationClient.migrate.up()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error running migration: ${error}`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("All migrations completed successfully.")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default runMigrations
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
import dbMigrationClient from "@/db/db-migration-client"
|
||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
export async function runSeeds(): Promise<void> {
|
||||||
|
if (process.env.SKIP_SEEDING_UNLESS_EMPTY === "true") {
|
||||||
|
const count = await User.count({ logging: false })
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
logger.warn("Skipping seeding as SKIP_SEEDING_UNLESS_EMPTY set, and data already seeded.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbMigrationClient.seed.run()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error running seeds: ${error}`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
export default runSeeds
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
import * as fs from "fs/promises"
|
||||||
|
import * as path from "path"
|
||||||
|
|
||||||
|
const NON_INITIALIZER_REGEX = /^index\.(ts|js)$/
|
||||||
|
|
||||||
|
export async function importAndExecuteInitializers() {
|
||||||
|
const files = await fs.readdir(__dirname)
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (NON_INITIALIZER_REGEX.test(file)) continue
|
||||||
|
|
||||||
|
const modulePath = path.join(__dirname, file)
|
||||||
|
logger.info(`Running initializer: ${modulePath}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: initializerAction } = await require(modulePath)
|
||||||
|
await initializerAction()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to run initializer: ${modulePath}`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
// TODO: add some kind of middleware that 503s? if initialization failed?
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
await importAndExecuteInitializers()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to complete initialization!")
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
})()
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# api/src/integrations/README.md
|
||||||
|
|
||||||
|
Integrations are api integrations with external services.
|
||||||
|
They might package a bunch of api calls, or just one.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
import { AUTH0_DOMAIN } from "@/config"
|
||||||
|
|
||||||
|
const auth0Api = axios.create({
|
||||||
|
baseURL: AUTH0_DOMAIN,
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Auth0UserInfo {
|
||||||
|
email: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
auth0Subject: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Auth0Response {
|
||||||
|
sub: string // "auth0|6241ec44e5b4a700693df293"
|
||||||
|
given_name: string // "Jane"
|
||||||
|
family_name: string // "Doe"
|
||||||
|
nickname: string // "Jane"
|
||||||
|
name: string // "Jane Doe"
|
||||||
|
picture: string // https://s.gravatar.com/avatar/1234567890abcdef1234567890abcdef?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fmb.png
|
||||||
|
updated_at: string // "2023-10-30T17:25:52.975Z"
|
||||||
|
email: string // "janedoe@gmail.com"
|
||||||
|
email_verified: boolean // true
|
||||||
|
oid?: string // 11111111-2222-3333-4444-555555555555"
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Auth0PayloadError extends Error {
|
||||||
|
constructor(data: unknown) {
|
||||||
|
super(`Payload from Auth0 is strange or failed for: ${JSON.stringify(data)}`)
|
||||||
|
this.name = "Auth0PayloadError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth0Integration = {
|
||||||
|
async getUserInfo(token: string): Promise<Auth0UserInfo> {
|
||||||
|
const { data }: { data: Auth0Response } = await auth0Api.get("/userinfo", {
|
||||||
|
headers: { authorization: token },
|
||||||
|
})
|
||||||
|
|
||||||
|
const firstName = data.given_name || "UNKNOWN"
|
||||||
|
const lastName = data.family_name || "UNKNOWN"
|
||||||
|
const fallbackEmail = `${firstName}.${lastName}@yukon-no-email.ca`
|
||||||
|
const email = data.email || fallbackEmail
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth0Subject: data.sub,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default auth0Integration
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
auth0Integration,
|
||||||
|
Auth0PayloadError,
|
||||||
|
type Auth0UserInfo,
|
||||||
|
} from "./auth0-integration"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import logger from "@/utils/logger"
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type HasNoArgsConstructor<T> = T extends { new (): any } ? true : false
|
||||||
|
|
||||||
|
type CleanConstructorParameters<T extends typeof BaseJob> =
|
||||||
|
HasNoArgsConstructor<T> extends true ? [] : ConstructorParameters<T>
|
||||||
|
|
||||||
|
export class BaseJob {
|
||||||
|
protected filename: string
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
this.filename = args[0] || __filename
|
||||||
|
}
|
||||||
|
|
||||||
|
static perform<T extends typeof BaseJob>(
|
||||||
|
this: T,
|
||||||
|
...args: CleanConstructorParameters<T>
|
||||||
|
): ReturnType<InstanceType<T>["perform"]> {
|
||||||
|
try {
|
||||||
|
const instance = new this(...args)
|
||||||
|
const { filename } = instance
|
||||||
|
logger.debug(`## Performing Job: ${filename}`)
|
||||||
|
return instance.perform()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to perform job: ${error}`, { error })
|
||||||
|
throw new Error(`Failed to perform job: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
perform(): any {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseJob
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# api/src/middlewares/README.md
|
||||||
|
|
||||||
|
Middleware are actions that run before or after every request.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type NextFunction, type Response } from "express"
|
||||||
|
import { type Request as JwtRequest } from "express-jwt"
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
import { Auth0PayloadError } from "@/integrations/auth0-integration"
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { Users } from "@/services"
|
||||||
|
|
||||||
|
export type AuthorizationRequest = JwtRequest & {
|
||||||
|
currentUser?: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires api/src/middlewares/jwt-middleware.ts to be run first
|
||||||
|
*
|
||||||
|
* I'd love to merge that code in here at some point, or make all this code a controller "before action"
|
||||||
|
* I'm uncomfortable with creating users automatically here, I'd rather the front-end requested
|
||||||
|
* user creation directly, and might switch to that in the future.
|
||||||
|
*
|
||||||
|
* NOTE: must be kept in sync with api/tests/support/mock-current-user.ts
|
||||||
|
*/
|
||||||
|
export async function authorizationMiddleware(
|
||||||
|
req: AuthorizationRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const user = await User.withScope(["asCurrentUser"]).findOne({
|
||||||
|
where: {
|
||||||
|
auth0Subject: req.auth?.sub || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isNil(user)) {
|
||||||
|
req.currentUser = user
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = req.headers.authorization || ""
|
||||||
|
const user = await Users.EnsureFromAuth0TokenService.perform(token)
|
||||||
|
req.currentUser = user
|
||||||
|
return next()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error ensuring user from Auth0 token ${error}`, { error })
|
||||||
|
|
||||||
|
if (error instanceof Auth0PayloadError) {
|
||||||
|
return res.status(502).json({ message: "External authorization api failed." })
|
||||||
|
} else {
|
||||||
|
return res.status(401).json({ message: "User authentication failed." })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { authorizationMiddleware } from "./authorization-middleware"
|
||||||
|
export { jwtMiddleware } from "./jwt-middleware"
|
||||||
|
export { requestLoggerMiddleware } from "./request-logger-middleware"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expressjwt as jwt } from "express-jwt"
|
||||||
|
import jwksRsa, { type GetVerificationKey } from "jwks-rsa"
|
||||||
|
|
||||||
|
import { AUTH0_DOMAIN, AUTH0_AUDIENCE, NODE_ENV } from "@/config"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
if (NODE_ENV !== "test") {
|
||||||
|
logger.info(`AUTH0_DOMAIN - ${AUTH0_DOMAIN}/.well-known/jwks.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: investigate converting this to an integration or utility of the authorization middleware
|
||||||
|
export const jwtMiddleware = jwt({
|
||||||
|
secret: jwksRsa.expressJwtSecret({
|
||||||
|
cache: true,
|
||||||
|
rateLimit: true,
|
||||||
|
jwksRequestsPerMinute: 5,
|
||||||
|
jwksUri: `${AUTH0_DOMAIN}/.well-known/jwks.json`,
|
||||||
|
}) as GetVerificationKey,
|
||||||
|
|
||||||
|
// Validate the audience and the issuer.
|
||||||
|
audience: AUTH0_AUDIENCE,
|
||||||
|
issuer: [`${AUTH0_DOMAIN}/`],
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default jwtMiddleware
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import morgan from "morgan"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
|
||||||
|
export const requestLoggerMiddleware = morgan(
|
||||||
|
":method :url :status :res[content-length] - :response-time ms",
|
||||||
|
{
|
||||||
|
stream: {
|
||||||
|
write: (message) => logger.http(message.trim()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default requestLoggerMiddleware
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
AttributeNames,
|
||||||
|
Attributes,
|
||||||
|
CreationOptional,
|
||||||
|
FindOptions,
|
||||||
|
Model,
|
||||||
|
ModelStatic,
|
||||||
|
Op,
|
||||||
|
WhereOptions,
|
||||||
|
} from "@sequelize/core"
|
||||||
|
|
||||||
|
import { searchFieldsByTermsFactory } from "@/utils/search-fields-by-terms-factory"
|
||||||
|
|
||||||
|
// See api/node_modules/@sequelize/core/lib/model.d.ts -> Model
|
||||||
|
export abstract class BaseModel<
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any
|
||||||
|
TModelAttributes extends {} = any,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
TCreationAttributes extends {} = TModelAttributes,
|
||||||
|
> extends Model<TModelAttributes, TCreationAttributes> {
|
||||||
|
declare id: CreationOptional<number>
|
||||||
|
|
||||||
|
static addSearchScope<M extends BaseModel>(this: ModelStatic<M>, fields: AttributeNames<M>[]) {
|
||||||
|
const searchScopeFunction = searchFieldsByTermsFactory<M>(fields)
|
||||||
|
this.addScope("search", searchScopeFunction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// static findByPk<M extends Model, R = Attributes<M>>(
|
||||||
|
// this: ModelStatic<M>,
|
||||||
|
// identifier: unknown,
|
||||||
|
// options: FindByPkOptions<M> & { raw: true; rejectOnEmpty?: false },
|
||||||
|
// ): Promise<R | null>;
|
||||||
|
// static findByPk<M extends Model, R = Attributes<M>>(
|
||||||
|
// this: ModelStatic<M>,
|
||||||
|
// identifier: unknown,
|
||||||
|
// options: NonNullFindByPkOptions<M> & { raw: true },
|
||||||
|
// ): Promise<R>;
|
||||||
|
// static findByPk<M extends Model>(
|
||||||
|
// this: ModelStatic<M>,
|
||||||
|
// identifier: unknown,
|
||||||
|
// options: NonNullFindByPkOptions<M>,
|
||||||
|
// ): Promise<M>;
|
||||||
|
// static findByPk<M extends Model>(
|
||||||
|
// this: ModelStatic<M>,
|
||||||
|
// identifier: unknown,
|
||||||
|
// options?: FindByPkOptions<M>,
|
||||||
|
// ): Promise<M | null>;
|
||||||
|
public static async findByIdentifierOrPk<M extends BaseModel>(
|
||||||
|
this: ModelStatic<M>,
|
||||||
|
identifierOrPk: string | number,
|
||||||
|
options?: Omit<FindOptions<Attributes<M>>, "where">
|
||||||
|
): Promise<M | null> {
|
||||||
|
if (typeof identifierOrPk === "number" || !isNaN(Number(identifierOrPk))) {
|
||||||
|
const primaryKey = identifierOrPk
|
||||||
|
return this.findByPk(primaryKey, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = identifierOrPk
|
||||||
|
if (!("identifier" in this.getAttributes())) {
|
||||||
|
throw new Error(`${this.name} does not have a 'identifier' attribute.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findOne({
|
||||||
|
...options,
|
||||||
|
// @ts-expect-error - We know that the model has a slug attribute, and are ignoring the TS error
|
||||||
|
where: { identifier },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// See api/node_modules/@sequelize/core/lib/model.d.ts -> findAll
|
||||||
|
// Taken from https://api.rubyonrails.org/v7.1.0/classes/ActiveRecord/Batches.html#method-i-find_each
|
||||||
|
// Enforces sort by id, overwriting any supplied order
|
||||||
|
public static async findEach<M extends BaseModel>(
|
||||||
|
this: ModelStatic<M>,
|
||||||
|
processFunction: (record: M) => Promise<void>
|
||||||
|
): Promise<void>
|
||||||
|
public static async findEach<M extends BaseModel, R = Attributes<M>>(
|
||||||
|
this: ModelStatic<M>,
|
||||||
|
options: Omit<FindOptions<Attributes<M>>, "raw"> & {
|
||||||
|
raw: true
|
||||||
|
batchSize?: number
|
||||||
|
},
|
||||||
|
processFunction: (record: R) => Promise<void>
|
||||||
|
): Promise<void>
|
||||||
|
public static async findEach<M extends BaseModel>(
|
||||||
|
this: ModelStatic<M>,
|
||||||
|
options: FindOptions<Attributes<M>> & {
|
||||||
|
batchSize?: number
|
||||||
|
},
|
||||||
|
processFunction: (record: M) => Promise<void>
|
||||||
|
): Promise<void>
|
||||||
|
public static async findEach<M extends BaseModel, R = Attributes<M>>(
|
||||||
|
this: ModelStatic<M>,
|
||||||
|
optionsOrFunction:
|
||||||
|
| ((record: M) => Promise<void>)
|
||||||
|
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
|
||||||
|
| (FindOptions<Attributes<M>> & { batchSize?: number }),
|
||||||
|
maybeFunction?: (record: R | M) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
let options:
|
||||||
|
| (FindOptions<Attributes<M>> & { batchSize?: number })
|
||||||
|
| (Omit<FindOptions<Attributes<M>>, "raw"> & { raw: true; batchSize?: number })
|
||||||
|
|
||||||
|
// TODO: fix types so that process function is M when not raw
|
||||||
|
// and R when raw. Raw is usable, just incorrectly typed.
|
||||||
|
let processFunction: (record: M) => Promise<void>
|
||||||
|
|
||||||
|
if (typeof optionsOrFunction === "function") {
|
||||||
|
options = {}
|
||||||
|
processFunction = optionsOrFunction
|
||||||
|
} else if (maybeFunction === undefined) {
|
||||||
|
throw new Error("findEach requires a processFunction")
|
||||||
|
} else {
|
||||||
|
options = optionsOrFunction
|
||||||
|
processFunction = maybeFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = options.batchSize ?? 1000
|
||||||
|
let lastId = 0
|
||||||
|
let continueProcessing = true
|
||||||
|
|
||||||
|
while (continueProcessing) {
|
||||||
|
// TODO: fix where option types so cast is not needed
|
||||||
|
const whereClause = {
|
||||||
|
...options.where,
|
||||||
|
id: { [Op.gt]: lastId },
|
||||||
|
} as WhereOptions<Attributes<M>>
|
||||||
|
const records = await this.findAll({
|
||||||
|
...options,
|
||||||
|
where: whereClause,
|
||||||
|
limit: batchSize,
|
||||||
|
order: [["id", "ASC"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
await processFunction(record)
|
||||||
|
lastId = record.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.length < batchSize) {
|
||||||
|
continueProcessing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseModel
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import db from "@/db/db-client"
|
||||||
|
|
||||||
|
// Models
|
||||||
|
import User, { UserRoles } from "@/models/user"
|
||||||
|
|
||||||
|
db.addModels([
|
||||||
|
User,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Lazy load scopes
|
||||||
|
User.establishScopes()
|
||||||
|
|
||||||
|
export {
|
||||||
|
User,
|
||||||
|
UserRoles,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special db instance will all models loaded
|
||||||
|
export default db
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
type CreationOptional,
|
||||||
|
DataTypes,
|
||||||
|
InferAttributes,
|
||||||
|
InferCreationAttributes,
|
||||||
|
type NonAttribute,
|
||||||
|
sql,
|
||||||
|
} from "@sequelize/core"
|
||||||
|
import {
|
||||||
|
Attribute,
|
||||||
|
AutoIncrement,
|
||||||
|
Default,
|
||||||
|
Index,
|
||||||
|
NotNull,
|
||||||
|
PrimaryKey,
|
||||||
|
ValidateAttribute,
|
||||||
|
} from "@sequelize/core/decorators-legacy"
|
||||||
|
import { isArray, isNil } from "lodash"
|
||||||
|
|
||||||
|
import BaseModel from "@/models/base-model"
|
||||||
|
|
||||||
|
/** Keep in sync with web/src/api/users-api.ts */
|
||||||
|
export enum UserRoles {
|
||||||
|
SYSTEM_ADMIN = "system_admin",
|
||||||
|
USER = "user",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class User extends BaseModel<InferAttributes<User>, InferCreationAttributes<User>> {
|
||||||
|
static readonly Roles = UserRoles
|
||||||
|
|
||||||
|
@Attribute(DataTypes.INTEGER)
|
||||||
|
@PrimaryKey
|
||||||
|
@AutoIncrement
|
||||||
|
declare id: CreationOptional<number>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(100))
|
||||||
|
@NotNull
|
||||||
|
@Index({ unique: true })
|
||||||
|
declare email: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(100))
|
||||||
|
@NotNull
|
||||||
|
@Index({ unique: true })
|
||||||
|
declare auth0Subject: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(100))
|
||||||
|
@NotNull
|
||||||
|
declare firstName: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(100))
|
||||||
|
@NotNull
|
||||||
|
declare lastName: string
|
||||||
|
|
||||||
|
@Attribute(DataTypes.STRING(200))
|
||||||
|
@NotNull
|
||||||
|
declare displayName: string
|
||||||
|
|
||||||
|
@Attribute({
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
get() {
|
||||||
|
const roles = this.getDataValue("roles")
|
||||||
|
if (isNil(roles)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return roles.split(",")
|
||||||
|
},
|
||||||
|
set(value: string[]) {
|
||||||
|
this.setDataValue("roles", value.join(","))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@NotNull
|
||||||
|
@ValidateAttribute({
|
||||||
|
validator: (valueString: string | string[]) => {
|
||||||
|
const value = isArray(valueString) ? valueString : valueString.split(",")
|
||||||
|
const validRoles = Object.values(UserRoles) as string[]
|
||||||
|
const invalidRoles = value.filter((role) => !validRoles.includes(role))
|
||||||
|
if (invalidRoles.length > 0) throw new Error(`Invalid role: ${invalidRoles.join(", ")}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
declare roles: UserRoles[]
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare createdAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
@NotNull
|
||||||
|
@Default(sql.literal("CURRENT_TIMESTAMP"))
|
||||||
|
declare updatedAt: CreationOptional<Date>
|
||||||
|
|
||||||
|
@Attribute(DataTypes.DATE(0))
|
||||||
|
declare deletedAt: Date | null
|
||||||
|
|
||||||
|
// Magic Attributes
|
||||||
|
get isSystemAdmin(): NonAttribute<boolean> {
|
||||||
|
return this.roles.some((role) => role === UserRoles.SYSTEM_ADMIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Associations
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
static establishScopes(): void {
|
||||||
|
this.addSearchScope(["firstName", "lastName", "displayName", "email"])
|
||||||
|
|
||||||
|
this.addScope("asCurrentUser", {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default User
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Policies
|
||||||
|
|
||||||
|
Policies are used to control access to data in a controller, before it is returned to the client.
|
||||||
|
Polices can be used in the following ways:
|
||||||
|
|
||||||
|
1. Build a policy instance and check the controller action matching boolean function.
|
||||||
|
Controller#update -> Policy#update
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessGrantsController extends BaseController {
|
||||||
|
async update() {
|
||||||
|
const accessGrant = await this.loadAccessGrant()
|
||||||
|
if (isNil(accessGrant)) {
|
||||||
|
return this.response.status(404).json({ message: "Access grant not found." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(accessGrant)
|
||||||
|
if (!policy.update()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to update access grants on this dataset." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
|
||||||
|
try {
|
||||||
|
const updatedAccessGrant = await UpdateService.perform(
|
||||||
|
accessGrant,
|
||||||
|
permittedAttributes,
|
||||||
|
this.currentUser
|
||||||
|
)
|
||||||
|
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
|
||||||
|
} catch (error) {
|
||||||
|
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAccessGrant(): Promise<AccessGrant | null> {
|
||||||
|
return AccessGrant.findByPk(this.params.accessGrantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(accessGrant: AccessGrant) {
|
||||||
|
return new AccessGrantsPolicy(this.currentUser, accessGrant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. The previous example also demostrates a second way of using policies. The "permitted attributes" pattern. A policy can also be used to provide an "allow list" of attributes that a user is allowed to submit for a given controller action.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
|
||||||
|
permittedAttributes(): Path[] {
|
||||||
|
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Policies can also be used to restrict the results of an "index" or list action in a controller.
|
||||||
|
In this case a bunch of scoping conditions are built up, and then passed to the "apply scope" function. This produces a query that, when executed, will only return the records that the current user is allowed to see.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessGrantsController extends BaseController<AccessGrant> {
|
||||||
|
async index() {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const scopes = this.buildFilterScopes()
|
||||||
|
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
|
||||||
|
|
||||||
|
const totalCount = await scopedAccessGrants.count({ where })
|
||||||
|
const accessGrants = await scopedAccessGrants.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.response.json({ accessGrants, totalCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Policy#policyScope
|
||||||
|
|
||||||
|
The `policyScope` method is used to add a scope to the given model. This scope is permanently added to the model, though it likely shouldn't be used outside of the policy.
|
||||||
|
|
||||||
|
i.e.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessRequestsPolicy extends PolicyFactory(AccessRequest) {
|
||||||
|
static policyScope(user: User): FindOptions<Attributes<AccessRequest>> {
|
||||||
|
if (user.isSystemAdmin || user.isBusinessAnalyst) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isDataOwner) {
|
||||||
|
return {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "dataset",
|
||||||
|
where: {
|
||||||
|
ownerId: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: {
|
||||||
|
requestorId: user.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
can be considered equivalent to
|
||||||
|
|
||||||
|
```ts
|
||||||
|
AccessReqeuest.addScope("policyScope", (user: User) => {
|
||||||
|
if (user.isSystemAdmin || user.isBusinessAnalyst) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isDataOwner) {
|
||||||
|
return {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
association: "dataset",
|
||||||
|
where: {
|
||||||
|
ownerId: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: {
|
||||||
|
requestorId: user.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
# Full Example
|
||||||
|
|
||||||
|
Here is a simple example of a controller using a policy to control access to a resource.
|
||||||
|
The full cases might be more complex, but the "policy" pattern leaves space for that complexity to exist without cluttering the controller.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessGrantsController extends BaseController<AccessGrant> {
|
||||||
|
async index() {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const scopes = this.buildFilterScopes()
|
||||||
|
const scopedAccessGrants = AccessGrantsPolicy.applyScope(scopes, this.currentUser)
|
||||||
|
|
||||||
|
const totalCount = await scopedAccessGrants.count({ where })
|
||||||
|
const accessGrants = await scopedAccessGrants.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.response.json({ accessGrants, totalCount })
|
||||||
|
}
|
||||||
|
|
||||||
|
async create() {
|
||||||
|
const accessGrant = await this.buildAccessGrant()
|
||||||
|
if (isNil(accessGrant)) {
|
||||||
|
return this.response.status(404).json({ message: "Dataset not found." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(accessGrant)
|
||||||
|
if (!policy.create()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to add access grants for this dataset." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
|
||||||
|
try {
|
||||||
|
const accessGrant = await CreateService.perform(permittedAttributes, this.currentUser)
|
||||||
|
return this.response.status(201).json({ accessGrant })
|
||||||
|
} catch (error) {
|
||||||
|
return this.response.status(422).json({ message: `Access grant creation failed: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
const accessGrant = await this.loadAccessGrant()
|
||||||
|
if (isNil(accessGrant)) {
|
||||||
|
return this.response.status(404).json({ message: "Access grant not found." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(accessGrant)
|
||||||
|
if (!policy.update()) {
|
||||||
|
return this.response
|
||||||
|
.status(403)
|
||||||
|
.json({ message: "You are not authorized to update access grants on this dataset." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
|
||||||
|
try {
|
||||||
|
const updatedAccessGrant = await UpdateService.perform(
|
||||||
|
accessGrant,
|
||||||
|
permittedAttributes,
|
||||||
|
this.currentUser
|
||||||
|
)
|
||||||
|
return this.response.status(200).json({ accessGrant: updatedAccessGrant })
|
||||||
|
} catch (error) {
|
||||||
|
return this.response.status(422).json({ message: `Access grant update failed: ${error}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildAccessGrant(): Promise<AccessGrant> {
|
||||||
|
return AccessGrant.build(this.request.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAccessGrant(): Promise<AccessGrant | null> {
|
||||||
|
return AccessGrant.findByPk(this.params.accessGrantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPolicy(accessGrant: AccessGrant) {
|
||||||
|
return new AccessGrantsPolicy(this.currentUser, accessGrant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and the policy
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export class AccessGrantsPolicy extends BasePolicy<AccessGrant> {
|
||||||
|
create(): boolean {
|
||||||
|
// some code that might returns true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): boolean {
|
||||||
|
// some code that might returns true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): boolean {
|
||||||
|
// some code that might returns true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributes(): Path[] {
|
||||||
|
return ["supportId", "grantLevel", "accessType", "isProjectDescriptionRequired"]
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForCreate(): Path[] {
|
||||||
|
return ["datasetId", ...this.permittedAttributes()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { ModelStatic, Model, Attributes, FindOptions, ScopeOptions, literal } from "@sequelize/core"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { Path, deepPick } from "@/utils/deep-pick"
|
||||||
|
import { isInteger, isNil } from "lodash"
|
||||||
|
|
||||||
|
export type Actions = "show" | "create" | "update" | "destroy"
|
||||||
|
|
||||||
|
export const NO_RECORDS_SCOPE = { where: literal("1 = 0") }
|
||||||
|
export const ALL_RECORDS_SCOPE = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See PolicyFactory below for policy with scope helpers
|
||||||
|
*/
|
||||||
|
export class BasePolicy<M extends Model> {
|
||||||
|
protected user: User
|
||||||
|
protected record: M
|
||||||
|
|
||||||
|
constructor(user: User, record: M) {
|
||||||
|
this.user = user
|
||||||
|
this.record = record
|
||||||
|
}
|
||||||
|
|
||||||
|
show(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
static policyScope<M extends Model>(user: User, ...args: unknown[]): FindOptions<Attributes<M>> {
|
||||||
|
throw new Error("Derived classes must implement policyScope method")
|
||||||
|
}
|
||||||
|
|
||||||
|
permitAttributes(record: Partial<M>): Partial<M> {
|
||||||
|
return deepPick(record, this.permittedAttributes())
|
||||||
|
}
|
||||||
|
|
||||||
|
permitAttributesForCreate(record: Partial<M>): Partial<M> {
|
||||||
|
if (this.permittedAttributesForCreate !== BasePolicy.prototype.permittedAttributesForCreate) {
|
||||||
|
return deepPick(record, this.permittedAttributesForCreate())
|
||||||
|
} else {
|
||||||
|
return deepPick(record, this.permittedAttributes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permitAttributesForUpdate(record: Partial<M>): Partial<M> {
|
||||||
|
if (this.permittedAttributesForUpdate !== BasePolicy.prototype.permittedAttributesForUpdate) {
|
||||||
|
return deepPick(record, this.permittedAttributesForUpdate())
|
||||||
|
} else {
|
||||||
|
return deepPick(record, this.permittedAttributes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributes(): Path[] {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForCreate(): Path[] {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForUpdate(): Path[] {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
setNumberNullIfEmpty(input: number | null | undefined): number | null | undefined {
|
||||||
|
let output = input
|
||||||
|
if (!isNil(input) && !isInteger(input)) output = undefined
|
||||||
|
if (!isNil(input) && input == 0) output = undefined
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
setNumberZeroIfEmpty(input: number | null | undefined): number {
|
||||||
|
let output = input
|
||||||
|
if (!isNil(input) && !isInteger(input)) output = 0
|
||||||
|
if (!isNil(input) && input == 0) output = 0
|
||||||
|
return output ?? 0
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add to support return policy information via this.reponse.json({ someObject, policy })
|
||||||
|
*
|
||||||
|
* If this method becomes complex, it should be broken out into a serializer.
|
||||||
|
*
|
||||||
|
* @returns a JSON representation of the policy
|
||||||
|
*/
|
||||||
|
toJSON(): Record<Actions, boolean> {
|
||||||
|
return {
|
||||||
|
show: this.show(),
|
||||||
|
create: this.create(),
|
||||||
|
update: this.update(),
|
||||||
|
destroy: this.destroy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See api/node_modules/sequelize/types/model.d.ts -> Model -> scope
|
||||||
|
export type BaseScopeOptions = string | ScopeOptions
|
||||||
|
|
||||||
|
export const POLICY_SCOPE_NAME = "policyScope"
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AllArgsButFirstOne<T extends any[]> = T extends [any, ...infer Rest] ? Rest : never
|
||||||
|
|
||||||
|
export function PolicyFactory<M extends Model, T extends Model = M>(modelClass: ModelStatic<M>) {
|
||||||
|
const policyClass = class Policy extends BasePolicy<T> {
|
||||||
|
static applyScope<P extends typeof Policy>(
|
||||||
|
this: P,
|
||||||
|
scopes: BaseScopeOptions[],
|
||||||
|
user: User,
|
||||||
|
...extraPolicyScopeArgs: AllArgsButFirstOne<Parameters<P["policyScope"]>>
|
||||||
|
): ModelStatic<M> {
|
||||||
|
this.ensurePolicyScope()
|
||||||
|
return modelClass.withScope([
|
||||||
|
...scopes,
|
||||||
|
{ method: [POLICY_SCOPE_NAME, user, ...extraPolicyScopeArgs] },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just in time scope creation for model class.
|
||||||
|
* TODO: to have scope creation occur at definition time, instead of execution time.
|
||||||
|
*/
|
||||||
|
static ensurePolicyScope() {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(modelClass.options.scopes, POLICY_SCOPE_NAME)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelClass.addScope(POLICY_SCOPE_NAME, this.policyScope.bind(modelClass))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return policyClass
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PolicyFactory
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Policy Bundles
|
||||||
|
export { type BaseScopeOptions } from "./base-policy"
|
||||||
|
export { UsersPolicy } from "./users-policy"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Attributes, FindOptions } from "@sequelize/core"
|
||||||
|
|
||||||
|
import { Path } from "@/utils/deep-pick"
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { ALL_RECORDS_SCOPE, PolicyFactory } from "@/policies/base-policy"
|
||||||
|
|
||||||
|
export class UsersPolicy extends PolicyFactory(User) {
|
||||||
|
show(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.user.id === this.record.id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
update(): boolean {
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.user.id === this.record.id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): boolean {
|
||||||
|
if (this.user.id === this.record.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.user.isSystemAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributes(): Path[] {
|
||||||
|
const attributes: (keyof Attributes<User>)[] = [
|
||||||
|
"email",
|
||||||
|
"auth0Subject",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"displayName",
|
||||||
|
]
|
||||||
|
|
||||||
|
return attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForCreate(): Path[] {
|
||||||
|
return [...this.permittedAttributes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
permittedAttributesForUpdate(): Path[] {
|
||||||
|
return [...this.permittedAttributes()]
|
||||||
|
}
|
||||||
|
|
||||||
|
static policyScope(user: User): FindOptions<Attributes<User>> {
|
||||||
|
if (user.isSystemAdmin) return ALL_RECORDS_SCOPE
|
||||||
|
|
||||||
|
return { where: { id: user.id } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersPolicy
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import path from "path"
|
||||||
|
import fs from "fs"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Router,
|
||||||
|
type Request,
|
||||||
|
type Response,
|
||||||
|
type ErrorRequestHandler,
|
||||||
|
type NextFunction,
|
||||||
|
} from "express"
|
||||||
|
import { UnauthorizedError } from "express-jwt"
|
||||||
|
import { template } from "lodash"
|
||||||
|
|
||||||
|
import { APPLICATION_NAME, GIT_COMMIT_HASH, NODE_ENV, RELEASE_TAG } from "@/config"
|
||||||
|
import { logger } from "@/utils/logger"
|
||||||
|
|
||||||
|
import { jwtMiddleware, authorizationMiddleware } from "@/middlewares"
|
||||||
|
|
||||||
|
import { CurrentUserController, UsersController } from "@/controllers"
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
// non-api (no authentication is required) routes
|
||||||
|
router.route("/_status").get((_req: Request, res: Response) => {
|
||||||
|
return res.json({
|
||||||
|
RELEASE_TAG,
|
||||||
|
GIT_COMMIT_HASH,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// external (public) routes - no authentication required
|
||||||
|
|
||||||
|
// api routes
|
||||||
|
router.use("/api", jwtMiddleware, authorizationMiddleware)
|
||||||
|
|
||||||
|
router.route("/api/current-user").get(CurrentUserController.show)
|
||||||
|
|
||||||
|
router.route("/api/users").get(UsersController.index).post(UsersController.create)
|
||||||
|
router
|
||||||
|
.route("/api/users/:userId")
|
||||||
|
.get(UsersController.show)
|
||||||
|
.patch(UsersController.update)
|
||||||
|
.delete(UsersController.destroy)
|
||||||
|
|
||||||
|
// if no other routes match, return a 404
|
||||||
|
router.use("/api", (req: Request, res: Response) => {
|
||||||
|
return res.status(404).json({ message: "Not Found", url: req.path })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Special error handler for all api errors
|
||||||
|
// See https://expressjs.com/en/guide/error-handling.html#writing-error-handlers
|
||||||
|
router.use("/api", (err: ErrorRequestHandler, _req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof UnauthorizedError) {
|
||||||
|
logger.error(err)
|
||||||
|
return res.status(err.status).json({ message: err.inner.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if (err instanceof DatabaseError) {
|
||||||
|
logger.error(err)
|
||||||
|
return res.status(422).json({ message: "Invalid query against database." })
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
logger.error(err)
|
||||||
|
return res.status(500).json({ message: "Internal Server Error" })
|
||||||
|
})
|
||||||
|
|
||||||
|
// if no other non-api routes match, send the pretty 404 page
|
||||||
|
if (NODE_ENV == "development") {
|
||||||
|
router.use("/", (_req: Request, res: Response) => {
|
||||||
|
const templatePath = path.resolve(__dirname, "web/404.html")
|
||||||
|
try {
|
||||||
|
const templateString = fs.readFileSync(templatePath, "utf8")
|
||||||
|
const compiledTemplate = template(templateString)
|
||||||
|
const result = compiledTemplate({
|
||||||
|
applicationName: APPLICATION_NAME,
|
||||||
|
releaseTag: RELEASE_TAG,
|
||||||
|
gitCommitHash: GIT_COMMIT_HASH,
|
||||||
|
})
|
||||||
|
return res.status(404).send(result)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error)
|
||||||
|
return res.status(500).send(`Error building 404 page: ${error}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { CronJob } from "cron"
|
||||||
|
|
||||||
|
import { API_PORT, APPLICATION_NAME } from "@/config"
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import withLoggingFactory from "@/utils/with-logging-factory"
|
||||||
|
|
||||||
|
logger.info(`${APPLICATION_NAME} JOBS listenting on port ${API_PORT}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://www.npmjs.com/package/cron.
|
||||||
|
*
|
||||||
|
* Most useful debugging option is `runOnInit: true`, which will immediately executes the job.
|
||||||
|
*
|
||||||
|
* Allowed fields
|
||||||
|
* # ┌────────────── second (optional)
|
||||||
|
* # │ ┌──────────── minute
|
||||||
|
* # │ │ ┌────────── hour
|
||||||
|
* # │ │ │ ┌──────── day of month
|
||||||
|
* # │ │ │ │ ┌────── month
|
||||||
|
* # │ │ │ │ │ ┌──── day of week
|
||||||
|
* # │ │ │ │ │ │
|
||||||
|
* # │ │ │ │ │ │
|
||||||
|
* # * * * * * *
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function enqueueJobs() {}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
;(async () => {
|
||||||
|
logger.debug("Enqueuing jobs...")
|
||||||
|
try {
|
||||||
|
await enqueueJobs()
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to enqueue jobs!")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Serializers
|
||||||
|
|
||||||
|
Serializers take model data, and add or remove fields. They are used to convert a database representation of a model to a front-end representation of a model. This might include removing fields that should not be exposed to the front-end, or adding fields that are derived from the database representation.
|
||||||
|
|
||||||
|
Serializers are used in controllers to convert from a database representation to a front-end data packet. Serializers should not be used for general data formating such as date or money formatting, as formatting those kinds of things in the front-end is generally more flexible.
|
||||||
|
|
||||||
|
e.g. Usage in a Controller might look like this
|
||||||
|
Note that the BaseSerializer supports passing either an array or a single model to the `perform` method.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { isNil } from "lodash"
|
||||||
|
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { UsersPolicy } from "@/policies"
|
||||||
|
import { CreateService } from "@/services/users"
|
||||||
|
import { IndexSerializer } from "@/serializers/users"
|
||||||
|
import BaseController from "@/controllers/base-controller"
|
||||||
|
|
||||||
|
export class FormsController extends BaseController {
|
||||||
|
async index() {
|
||||||
|
try {
|
||||||
|
const where = this.buildWhere()
|
||||||
|
const scopes = this.buildFilterScopes()
|
||||||
|
const scopedUsers = UsersPolicy.applyScope(scopes, this.currentUser)
|
||||||
|
|
||||||
|
const totalCount = await scopedUsers.count({ where })
|
||||||
|
const users = await scopedUsers.findAll({
|
||||||
|
where,
|
||||||
|
limit: this.pagination.limit,
|
||||||
|
offset: this.pagination.offset,
|
||||||
|
})
|
||||||
|
const serializedUsers = IndexSerializer.perform(users)
|
||||||
|
return this.response.json({
|
||||||
|
users: serializedUsers,
|
||||||
|
totalCount,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching users" + error)
|
||||||
|
return this.response.status(400).json({
|
||||||
|
message: `Error fetching users: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
try {
|
||||||
|
const user = await this.loadUser()
|
||||||
|
if (isNil(user)) {
|
||||||
|
return this.response.status(404).json({
|
||||||
|
message: "User not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = this.buildPolicy(user)
|
||||||
|
if (!policy.show()) {
|
||||||
|
return this.response.status(403).json({
|
||||||
|
message: "You are not authorized to view this user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.response.json({ user, policy })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error fetching user" + error)
|
||||||
|
return this.response.status(400).json({
|
||||||
|
message: `Error fetching user: ${error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
type RemainingConstructorParameters<C extends new (...args: any[]) => any> = C extends new (
|
||||||
|
head: any,
|
||||||
|
...tail: infer TT
|
||||||
|
) => any
|
||||||
|
? TT
|
||||||
|
: []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseSerializer is a generic class that provides a common interface for all serializers.
|
||||||
|
* It is designed to be extended by other serializers, and provides a static `perform` method
|
||||||
|
* that can be used to serialize a single record or an array of records.
|
||||||
|
*
|
||||||
|
* The `perform` method is overloaded to handle both cases, and will return the serialized
|
||||||
|
* record or records based on the input. The return type is determined as the return type of the
|
||||||
|
* `perform` instance method of the subclass as a single value or an array of values.
|
||||||
|
*
|
||||||
|
* The `perform` takes its signature from the constructor of the subclass, while also allowing
|
||||||
|
* for an array of records to be passed in as the first argument.
|
||||||
|
*
|
||||||
|
* @param M The model type that the serializer is designed to handle
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* class TableSerializer extends BaseSerializer<Dataset> {
|
||||||
|
* constructor(
|
||||||
|
* protected record: Dataset,
|
||||||
|
* protected currentUser: User
|
||||||
|
* ) {
|
||||||
|
* super(record)
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* perform(): DatasetTableView {}
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* TableSerializer.perform(dataset, currentUser) // => DatasetTableView
|
||||||
|
* TableSerializer.perform([dataset1, dataset2], currentUser) // => [DatasetTableView, DatasetTableView]
|
||||||
|
*/
|
||||||
|
export class BaseSerializer<Model> {
|
||||||
|
constructor(protected record: Model) {}
|
||||||
|
|
||||||
|
// Overload for handling a single record
|
||||||
|
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
|
||||||
|
this: C,
|
||||||
|
...args: ConstructorParameters<C>
|
||||||
|
): ReturnType<InstanceType<C>["perform"]>
|
||||||
|
|
||||||
|
// Overload for handling an array of records
|
||||||
|
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
|
||||||
|
this: C,
|
||||||
|
...args: [ConstructorParameters<C>[0][], ...RemainingConstructorParameters<C>]
|
||||||
|
): ReturnType<InstanceType<C>["perform"]>[]
|
||||||
|
|
||||||
|
// Implementation of the perform method
|
||||||
|
static perform<T extends BaseSerializer<any>, C extends new (...args: any[]) => T>(
|
||||||
|
this: C,
|
||||||
|
...args:
|
||||||
|
| ConstructorParameters<C>
|
||||||
|
| [ConstructorParameters<C>[0][], ...RemainingConstructorParameters<C>]
|
||||||
|
): ReturnType<InstanceType<C>["perform"]> | ReturnType<InstanceType<C>["perform"]>[] {
|
||||||
|
if (Array.isArray(args[0])) {
|
||||||
|
const records = args[0] as ConstructorParameters<C>[0][]
|
||||||
|
return records.map((record) => {
|
||||||
|
const instance = new this(record, ...args.slice(1))
|
||||||
|
return instance.perform()
|
||||||
|
}) as ReturnType<InstanceType<C>["perform"]>[]
|
||||||
|
} else {
|
||||||
|
const instance = new this(...args)
|
||||||
|
return instance.perform() as ReturnType<InstanceType<C>["perform"]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
perform(): any {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseSerializer
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ShowSerializer } from "./show-serializer"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type UserShowView = Pick<
|
||||||
|
User,
|
||||||
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||||
|
>
|
||||||
|
|
||||||
|
export class ShowSerializer extends BaseSerializer<User> {
|
||||||
|
perform(): UserShowView {
|
||||||
|
return {
|
||||||
|
...pick(this.record, [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"displayName",
|
||||||
|
"roles",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowSerializer
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Bundled exports
|
||||||
|
export * as Users from "./users"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type UserIndexView = Pick<
|
||||||
|
User,
|
||||||
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles"
|
||||||
|
>
|
||||||
|
|
||||||
|
export class IndexSerializer extends BaseSerializer<User> {
|
||||||
|
perform(): UserIndexView {
|
||||||
|
return {
|
||||||
|
...pick(this.record, ["id", "email", "firstName", "lastName", "displayName", "roles"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IndexSerializer
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ShowSerializer } from "./show-serializer"
|
||||||
|
export { IndexSerializer } from "./index-serializer"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { pick } from "lodash"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseSerializer from "@/serializers/base-serializer"
|
||||||
|
|
||||||
|
export type UserShowView = Pick<
|
||||||
|
User,
|
||||||
|
"id" | "email" | "firstName" | "lastName" | "displayName" | "roles" | "createdAt" | "updatedAt"
|
||||||
|
>
|
||||||
|
|
||||||
|
export class ShowSerializer extends BaseSerializer<User> {
|
||||||
|
perform(): UserShowView {
|
||||||
|
return {
|
||||||
|
...pick(this.record, [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"displayName",
|
||||||
|
"roles",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowSerializer
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { API_PORT, APPLICATION_NAME } from "@/config"
|
||||||
|
import logger from "@/utils/logger"
|
||||||
|
import app from "@/app"
|
||||||
|
import { enqueueJobs } from "@/scheduler"
|
||||||
|
|
||||||
|
app.listen(API_PORT, async () => {
|
||||||
|
logger.info(`${APPLICATION_NAME} API listenting on port ${API_PORT}`)
|
||||||
|
await enqueueJobs()
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type HasNoArgsConstructor<T> = T extends { new (): any } ? true : false
|
||||||
|
|
||||||
|
type CleanConstructorParameters<T extends typeof BaseService> =
|
||||||
|
HasNoArgsConstructor<T> extends true ? [] : ConstructorParameters<T>
|
||||||
|
|
||||||
|
export class BaseService {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
|
||||||
|
constructor(...args: any[]) {}
|
||||||
|
|
||||||
|
static perform<T extends typeof BaseService>(
|
||||||
|
this: T,
|
||||||
|
...args: CleanConstructorParameters<T>
|
||||||
|
): ReturnType<InstanceType<T>["perform"]> {
|
||||||
|
const instance = new this(...args)
|
||||||
|
return instance.perform()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
perform(): any {
|
||||||
|
throw new Error("Not Implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseService
|
||||||
|
|
||||||
|
// Type Testing - keeping until I have real tests implemented
|
||||||
|
// class AsyncService extends BaseService {
|
||||||
|
// private param1: number
|
||||||
|
|
||||||
|
// constructor(param1: number) {
|
||||||
|
// super()
|
||||||
|
// this.param1 = param1
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async perform(): Promise<string[]> {
|
||||||
|
// return ["async-string1", "async-string2"]
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// class NonAsyncService extends BaseService {
|
||||||
|
// perform(): string {
|
||||||
|
// return "non-async-string"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const param1 = 77
|
||||||
|
// AsyncService.perform(param1).then((result: string[]) => {
|
||||||
|
// logger.log(result)
|
||||||
|
// })
|
||||||
|
|
||||||
|
// const result = NonAsyncService.perform()
|
||||||
|
// logger.log(result)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * as Users from "./users"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { CreationAttributes } from "@sequelize/core"
|
||||||
|
import { isNil, random } from "lodash"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export type UserCreationAttributes = Partial<CreationAttributes<User>>
|
||||||
|
|
||||||
|
export class CreateService extends BaseService {
|
||||||
|
constructor(private attributes: UserCreationAttributes) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<User> {
|
||||||
|
const { email, auth0Subject, roles, ...optionalAttributes } = this.attributes
|
||||||
|
|
||||||
|
if (isNil(email)) {
|
||||||
|
throw new Error("Email is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNil(auth0Subject)) {
|
||||||
|
throw new Error("Auth0 Subject is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [emailLocalPart] = email.split("@")
|
||||||
|
/**
|
||||||
|
* Yep, if we don't have enough data, your name becomes your email split randomly.
|
||||||
|
* This way we can at least have a first name and last name,
|
||||||
|
* and the first and last name are likely to be distinct.
|
||||||
|
*/
|
||||||
|
const randomSplit = random(1, emailLocalPart.length - 2)
|
||||||
|
const [firstNameFallback, lastNameFallback] = emailLocalPart.includes(".")
|
||||||
|
? emailLocalPart.split(".")
|
||||||
|
: [emailLocalPart.slice(0, randomSplit), emailLocalPart.slice(randomSplit)]
|
||||||
|
const { firstName, lastName } = optionalAttributes
|
||||||
|
const firstNameOrFallback = firstName || firstNameFallback
|
||||||
|
const lastNameOrFallback = lastName || lastNameFallback
|
||||||
|
|
||||||
|
const user = await User.create({
|
||||||
|
...optionalAttributes,
|
||||||
|
email,
|
||||||
|
auth0Subject: auth0Subject,
|
||||||
|
firstName: firstNameOrFallback,
|
||||||
|
lastName: lastNameOrFallback,
|
||||||
|
displayName: `${firstNameOrFallback} ${lastNameOrFallback}`,
|
||||||
|
roles: roles ?? [User.Roles.USER],
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateService
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { User } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export class DestroyService extends BaseService {
|
||||||
|
constructor(private user: User) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform() {
|
||||||
|
throw new Error("Not implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DestroyService
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { auth0Integration } from "@/integrations"
|
||||||
|
import { User } from "@/models"
|
||||||
|
import { Op } from "@sequelize/core"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
import { Users } from "@/services"
|
||||||
|
|
||||||
|
export class EnsureFromAuth0TokenService extends BaseService {
|
||||||
|
constructor(private token: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<User> {
|
||||||
|
const { auth0Subject, email, firstName, lastName } = await auth0Integration.getUserInfo(
|
||||||
|
this.token
|
||||||
|
)
|
||||||
|
|
||||||
|
const existingUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||||
|
where: { auth0Subject },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTimeUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||||
|
where: { [Op.or]: [{ auth0Subject: email }, { email: email }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (firstTimeUser) {
|
||||||
|
await firstTimeUser.update({ auth0Subject })
|
||||||
|
return firstTimeUser
|
||||||
|
}
|
||||||
|
|
||||||
|
await Users.CreateService.perform({
|
||||||
|
auth0Subject,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newUser = await User.withScope(["asCurrentUser"]).findOne({
|
||||||
|
where: { auth0Subject },
|
||||||
|
rejectOnEmpty: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return newUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EnsureFromAuth0TokenService
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { CreateService } from "./create-service"
|
||||||
|
export { UpdateService } from "./update-service"
|
||||||
|
export { DestroyService } from "./destroy-service"
|
||||||
|
|
||||||
|
// Special Services
|
||||||
|
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Attributes } from "@sequelize/core"
|
||||||
|
|
||||||
|
import { User } from "@/models"
|
||||||
|
import BaseService from "@/services/base-service"
|
||||||
|
|
||||||
|
export type UserUpdateAttributes = Partial<Attributes<User>>
|
||||||
|
|
||||||
|
export class UpdateService extends BaseService {
|
||||||
|
constructor(
|
||||||
|
private user: User,
|
||||||
|
private attributes: UserUpdateAttributes
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async perform(): Promise<User> {
|
||||||
|
return this.user.update(this.attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateService
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function acronymize(name: string) {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.split(/[\s-]+/g)
|
||||||
|
.filter((word) => word[0] === word[0].toUpperCase())
|
||||||
|
.map((word) => {
|
||||||
|
if (!isNaN(parseInt(word[0]))) return word
|
||||||
|
|
||||||
|
return word[0]
|
||||||
|
})
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default acronymize
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
type AsArray<T> = T extends [] ? T : T[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps its argument in an array unless it is already an array (or array-like).
|
||||||
|
* See https://api.rubyonrails.org/classes/Array.html#method-c-wrap
|
||||||
|
*/
|
||||||
|
export function arrayWrap<T>(value: T | T[]): AsArray<T> {
|
||||||
|
return Array.isArray(value) ? (value as AsArray<T>) : ([value] as AsArray<T>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default arrayWrap
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Converts a base64 data URL to a Buffer
|
||||||
|
* @param dataUrl - Base64 data URL string (e.g., "data:image/png;base64,iVBORw0KGgo...")
|
||||||
|
* @returns Buffer containing the binary data, or null if input is null/undefined
|
||||||
|
*/
|
||||||
|
export function base64ToBuffer(dataUrl: string | null | undefined): Buffer | null {
|
||||||
|
if (!dataUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the base64 data from the data URL
|
||||||
|
// Format: data:image/png;base64,<base64-encoded-data>
|
||||||
|
const base64Match = dataUrl.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/)
|
||||||
|
|
||||||
|
if (!base64Match) {
|
||||||
|
// If it's not a data URL, assume it's already base64 encoded
|
||||||
|
return Buffer.from(dataUrl, "base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Data = base64Match[2]
|
||||||
|
return Buffer.from(base64Data, "base64")
|
||||||
|
}
|
||||||
|
|
||||||
|
export default base64ToBuffer
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Splits a string into chunks of a given size.
|
||||||
|
* Prefers first chunk to be the smallest, if string cannot be split evenly.
|
||||||
|
*
|
||||||
|
* e.g.
|
||||||
|
* chunkString("1234567890", 4) => ["12", "3456", "7890"]
|
||||||
|
*/
|
||||||
|
export function chunkString(string: string, chunkSize: number = 4): string[] {
|
||||||
|
const result = []
|
||||||
|
let currentIndex = string.length
|
||||||
|
|
||||||
|
// Loop from the end of the string and slice groups of chunkSize
|
||||||
|
while (currentIndex > 0) {
|
||||||
|
const start = Math.max(currentIndex - chunkSize, 0)
|
||||||
|
const chunk = string.slice(start, currentIndex)
|
||||||
|
result.unshift(chunk)
|
||||||
|
currentIndex -= chunkSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default chunkString
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Converts empty string values to null in an attributes object.
|
||||||
|
*
|
||||||
|
* When HTML form inputs are cleared, they send "" (empty string) which
|
||||||
|
* Sequelize rejects for numeric/decimal columns. This utility coerces
|
||||||
|
* those empty strings to null before the attributes reach the model.
|
||||||
|
*/
|
||||||
|
export function coerceEmptyStringsToNull<T extends Record<string, unknown>>(attributes: T): T {
|
||||||
|
const result: Record<string, unknown> = { ...attributes }
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (result[key] === "") {
|
||||||
|
result[key] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export default coerceEmptyStringsToNull
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Don't overuse this, it's not a full SQL parser.
|
||||||
|
* It's only purpose is to make SQL formatted by Sequelize 6 a bit more readable during development.
|
||||||
|
*/
|
||||||
|
export function compactSql(sql: string) {
|
||||||
|
const multiLineCommentPattern = /\/\*[\s\S]*?\*\//g
|
||||||
|
const singleLineCommentPattern = /--.*$/gm
|
||||||
|
const multiWhitespacePattern = /\s+/g
|
||||||
|
|
||||||
|
return sql
|
||||||
|
.replace(multiLineCommentPattern, "")
|
||||||
|
.replace(singleLineCommentPattern, "")
|
||||||
|
.replace(multiWhitespacePattern, " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default compactSql
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { has } from "lodash"
|
||||||
|
|
||||||
|
export function isCredentialFailure(error: unknown) {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
((has(error, "code") && error.code === "ELOGIN") ||
|
||||||
|
error.message.includes("Login failed for user"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSocketFailure(error: unknown) {
|
||||||
|
return error instanceof Error && has(error, "code") && error.code === "ESOCKET"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMissingDatabaseFailure(error: unknown) {
|
||||||
|
return error instanceof Error && has(error, "code") && error.code === "3D000"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNetworkFailure(error: unknown) {
|
||||||
|
return (
|
||||||
|
error instanceof Error &&
|
||||||
|
((has(error, "code") && error.code === "EAI_AGAIN") ||
|
||||||
|
error.message.includes("getaddrinfo EAI_AGAIN"))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
cloneDeep,
|
||||||
|
isArray,
|
||||||
|
isBoolean,
|
||||||
|
isNull,
|
||||||
|
isNumber,
|
||||||
|
isObject,
|
||||||
|
isString,
|
||||||
|
isUndefined,
|
||||||
|
} from "lodash"
|
||||||
|
|
||||||
|
export type Path =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
[key: string]: (string | Path)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Usage:
|
||||||
|
const object = {
|
||||||
|
a: 1,
|
||||||
|
b: 2,
|
||||||
|
c: {
|
||||||
|
d: 4,
|
||||||
|
f: 5,
|
||||||
|
},
|
||||||
|
g: [
|
||||||
|
{
|
||||||
|
h: 6,
|
||||||
|
i: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
h: 8,
|
||||||
|
i: 9,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = deepPick(object, ["a", { c: ["d"] }, { g: ["h"] }]);
|
||||||
|
console.log(picked); // Output: { a: 1, c: { d: 4 }, g: [{ h: 6 }, { h: 8 }] }
|
||||||
|
|
||||||
|
TODO: figure out how to do this without "any"
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function deepPick(object: any, paths: Path[]): any {
|
||||||
|
if (isArray(object)) {
|
||||||
|
return object.map((item) => deepPick(item, paths))
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return paths.reduce((result: any, path: Path) => {
|
||||||
|
if (isString(path)) {
|
||||||
|
if (path in object === false) return result
|
||||||
|
|
||||||
|
const value = cloneDeep(object[path])
|
||||||
|
if (isSimpleType(value)) {
|
||||||
|
result[path] = value
|
||||||
|
return result
|
||||||
|
} else if (isArray(value) && value.every(isSimpleType)) {
|
||||||
|
result[path] = value
|
||||||
|
return result
|
||||||
|
} else if (isArray(value) && value.every(isObject)) {
|
||||||
|
result[path] = []
|
||||||
|
return result
|
||||||
|
} else if (isObject(value)) {
|
||||||
|
result[path] = value
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported value type at path: ${path} -> ${JSON.stringify(value)}`)
|
||||||
|
}
|
||||||
|
} else if (isObject(path)) {
|
||||||
|
Object.entries(path).forEach(([path, nestedPaths]) => {
|
||||||
|
if (path in object === false) return
|
||||||
|
|
||||||
|
const value = cloneDeep(object[path])
|
||||||
|
if (isSimpleType(value)) {
|
||||||
|
result[path] = value
|
||||||
|
} else if (isArray(value) && value.every(isSimpleType)) {
|
||||||
|
result[path] = value
|
||||||
|
} else if (Array.isArray(value) && value.every(isObject)) {
|
||||||
|
result[path] = value.map((item) => deepPick(item, nestedPaths))
|
||||||
|
} else if (isObject(value)) {
|
||||||
|
result[path] = deepPick(value, nestedPaths)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported value structure at path: ${path} -> ${JSON.stringify(value)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported path type: ${path}`)
|
||||||
|
}
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSimpleType(value: unknown) {
|
||||||
|
return (
|
||||||
|
isString(value) || isNumber(value) || isBoolean(value) || isNull(value) || isUndefined(value)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
export function determineFiscalYear() {
|
||||||
|
const today = DateTime.local() // Get the current date
|
||||||
|
const fiscalYearStartMonth = 4 // Fiscal year starts in April
|
||||||
|
|
||||||
|
// If today's month is April or later, the fiscal year started this calendar year
|
||||||
|
if (today.month >= fiscalYearStartMonth) {
|
||||||
|
return today.year // Fiscal year is the current year
|
||||||
|
} else {
|
||||||
|
// If the month is before April, the fiscal year started last calendar year
|
||||||
|
return today.year - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default determineFiscalYear
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import qs from "qs"
|
||||||
|
|
||||||
|
export function enhancedQsDecoder(params: string) {
|
||||||
|
return qs.parse(params, {
|
||||||
|
strictNullHandling: true,
|
||||||
|
decoder(str, defaultDecoder, charset, type) {
|
||||||
|
if (type === "value") {
|
||||||
|
if (str === "true") return true
|
||||||
|
if (str === "false") return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultDecoder(str, defaultDecoder, charset)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default enhancedQsDecoder
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createLogger, format, transports } from "winston"
|
||||||
|
|
||||||
|
import { DEFAULT_LOG_LEVEL,} from "@/config"
|
||||||
|
|
||||||
|
export const consoleLogger = createLogger({
|
||||||
|
level: DEFAULT_LOG_LEVEL,
|
||||||
|
format: format.combine(format.colorize(), format.simple()),
|
||||||
|
transports: [new transports.Console()],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const logger = consoleLogger
|
||||||
|
|
||||||
|
export default logger
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { isArray, isNil } from "lodash"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
export function generateDiff<T>(
|
||||||
|
oldAttributes: T,
|
||||||
|
newAttributes: Partial<T>,
|
||||||
|
auditiableAttributes: Partial<T>
|
||||||
|
): string {
|
||||||
|
const diff = new Array<string>()
|
||||||
|
|
||||||
|
Object.keys(auditiableAttributes).forEach((key) => {
|
||||||
|
const oldValue = oldAttributes[key as keyof T]
|
||||||
|
const newValue = newAttributes[key as keyof T]
|
||||||
|
|
||||||
|
if (isArray(oldValue) || isArray(newValue)) {
|
||||||
|
const oVArray = (oldValue as unknown[]).join(", ")
|
||||||
|
const nVArray = (newValue as unknown[]).join(", ")
|
||||||
|
if (oVArray !== nVArray) {
|
||||||
|
diff.push(`${key}: '${oVArray}' => '${nVArray}'`)
|
||||||
|
}
|
||||||
|
} else if (!isNil(isDateTime(oldValue, newValue))) {
|
||||||
|
const res = isDateTime(oldValue, newValue)
|
||||||
|
if (res?.v1 !== res?.v2) {
|
||||||
|
diff.push(`${key}: '${res?.v1}' => '${res?.v2}'`)
|
||||||
|
}
|
||||||
|
} else if (typeof oldValue === "string" || typeof newValue === "string") {
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
|
||||||
|
}
|
||||||
|
} else if (typeof oldValue === "number" || typeof newValue === "number") {
|
||||||
|
if (oldValue !== newValue) {
|
||||||
|
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
|
||||||
|
}
|
||||||
|
} else if (oldValue !== newValue) {
|
||||||
|
diff.push(`${key}: '${oldValue}' => '${newValue}'`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (diff.length === 0) return "No changes detected"
|
||||||
|
|
||||||
|
return diff.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateTime(
|
||||||
|
value1: unknown,
|
||||||
|
value2: unknown
|
||||||
|
): { v1: string | null; v2: string | null } | null {
|
||||||
|
let v1 = null as string | null
|
||||||
|
let v2 = null as string | null
|
||||||
|
|
||||||
|
if (typeof value1 === "undefined" || value1 === null) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof value1 == "string") {
|
||||||
|
const v1Valid = DateTime.fromISO(value1).isValid
|
||||||
|
|
||||||
|
if (v1Valid) v1 = DateTime.fromISO(value1).toUTC().toISO()
|
||||||
|
}
|
||||||
|
if (typeof value1 == "object") {
|
||||||
|
const v1Valid = DateTime.fromJSDate(value1 as Date).isValid
|
||||||
|
if (v1Valid)
|
||||||
|
v1 = DateTime.fromJSDate(value1 as Date)
|
||||||
|
.toUTC()
|
||||||
|
.toISO()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value2 == "string") {
|
||||||
|
const v1Valid = DateTime.fromISO(value2).isValid
|
||||||
|
|
||||||
|
if (v1Valid) v2 = DateTime.fromISO(value2).toUTC().toISO()
|
||||||
|
}
|
||||||
|
if (typeof value2 == "object") {
|
||||||
|
const v1Valid = DateTime.fromJSDate(value2 as Date).isValid
|
||||||
|
if (v1Valid)
|
||||||
|
v2 = DateTime.fromJSDate(value2 as Date)
|
||||||
|
.toUTC()
|
||||||
|
.toISO()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNil(v1) || !isNil(v2)) return { v1, v2 }
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
AttributeNames,
|
||||||
|
Attributes,
|
||||||
|
FindOptions,
|
||||||
|
Model,
|
||||||
|
Op,
|
||||||
|
WhereOptions,
|
||||||
|
sql,
|
||||||
|
where,
|
||||||
|
} from "@sequelize/core"
|
||||||
|
|
||||||
|
import arrayWrap from "@/utils/array-wrap"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a search scope for Sequelize models that allows for custom SQL conditions per term.
|
||||||
|
*/
|
||||||
|
export function searchFieldsByTermsFactory<M extends Model>(
|
||||||
|
fields: AttributeNames<M>[]
|
||||||
|
): (termOrTerms: string | string[]) => FindOptions<Attributes<M>> {
|
||||||
|
return (termOrTerms: string | string[]): FindOptions<Attributes<M>> => {
|
||||||
|
const terms = arrayWrap(termOrTerms)
|
||||||
|
if (terms.length === 0) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: rebuild as successive scope calls once
|
||||||
|
// https://github.com/sequelize/sequelize/issues/17304 is fixed
|
||||||
|
// (we would no longer need the and operator in the where clause)
|
||||||
|
const whereQuery: {
|
||||||
|
[Op.and]?: WhereOptions<M>[]
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
const whereConditions: WhereOptions<M>[] = terms.map((term: string) => {
|
||||||
|
const termPattern = `%${term.toLowerCase()}%`
|
||||||
|
const fieldsQuery = fields.map((field) => {
|
||||||
|
return where(sql.fn("LOWER", sql.attribute(field)), Op.like, termPattern)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
[Op.or]: fieldsQuery,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
whereQuery[Op.and] = whereConditions
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: whereQuery,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default searchFieldsByTermsFactory
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function sleep(seconds: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default sleep
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function stripTrailingSlash(url: string) {
|
||||||
|
return url.endsWith("/") ? url.slice(0, -1) : url
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { last } from "lodash"
|
||||||
|
|
||||||
|
export function toSentence(items: string[]): string {
|
||||||
|
if (items.length === 0) return ""
|
||||||
|
if (items.length === 1) return items[0]
|
||||||
|
if (items.length === 2) return items.join(" and ")
|
||||||
|
|
||||||
|
const itemsExceptLast = items.slice(0, -1).join(", ")
|
||||||
|
const lastItem = last(items)
|
||||||
|
return `${itemsExceptLast}, and ${lastItem}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default toSentence
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import logger from "@/utils/logger"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps an async function with logging for start, completion, and errors.
|
||||||
|
* Accepts positional parameters like findEach.
|
||||||
|
*/
|
||||||
|
function withLoggingFactory(
|
||||||
|
description: string,
|
||||||
|
wrappedFunction: () => Promise<void>
|
||||||
|
): () => Promise<void>
|
||||||
|
|
||||||
|
function withLoggingFactory<T extends Record<string, unknown>>(
|
||||||
|
description: string,
|
||||||
|
context: T,
|
||||||
|
wrappedFunction: (context: T) => Promise<void>
|
||||||
|
): () => Promise<void>
|
||||||
|
|
||||||
|
function withLoggingFactory<T extends Record<string, unknown>>(
|
||||||
|
description: string,
|
||||||
|
contextOrFunction?: T | (() => Promise<void>),
|
||||||
|
wrappedFunction?: (context: T) => Promise<void>
|
||||||
|
): () => Promise<void> {
|
||||||
|
// 2-argument version: (description, wrappedFunction)
|
||||||
|
if (typeof contextOrFunction === "function") {
|
||||||
|
return async () => {
|
||||||
|
logger.info(`Starting: ${description}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await contextOrFunction()
|
||||||
|
logger.info(`Completed: ${description}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed: ${description}`, { error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3-argument version: (description, context, wrappedFunction)
|
||||||
|
if (typeof contextOrFunction !== "object") {
|
||||||
|
throw new Error("Missing context")
|
||||||
|
}
|
||||||
|
const context: T = contextOrFunction
|
||||||
|
|
||||||
|
if (typeof wrappedFunction !== "function") {
|
||||||
|
throw new Error("Missing wrapped function")
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
logger.info(`Starting: ${description} with ${context}`, { context })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wrappedFunction(context)
|
||||||
|
logger.info(`Completed: ${description} with ${context}`, { context })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed: ${description} with ${context}`, { context, error })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withLoggingFactory
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>404 Not Found</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Error 404</h1>
|
||||||
|
<p>Oops! The page you're looking doesn't exist.</p>
|
||||||
|
<hr />
|
||||||
|
<p>Site: ${applicationName}</p>
|
||||||
|
<p>Version: ${releaseTag}</p>
|
||||||
|
<p>Commit Hash: ${gitCommitHash}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0"
|
||||||
|
/>
|
||||||
|
<title>404 Not Found</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 100px;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Error 404</h1>
|
||||||
|
<p>This is a stub is meant to be replaced by the compiled front-end in production</p>
|
||||||
|
<hr />
|
||||||
|
<p>Site: Guardian</p>
|
||||||
|
<p>Version: 0.0.1</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# API service Tests
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Tests are written in [vitest](https://vitest.dev/guide/)
|
||||||
|
|
||||||
|
Test initialization goes like this:
|
||||||
|
|
||||||
|
1. `api/vitest.config.mts` loads the ts config and finds the appropriate setup functions.
|
||||||
|
|
||||||
|
2. Before running the tests, it runs the `globalSetup` function from `api/tests/global-setup.ts`. This does things like setting up the database and running migrations and base seeds.
|
||||||
|
|
||||||
|
3. Next it loads a specific test file triggers the `setupFiles` files, currently only `api/tests/setup.ts`. These setup files add callbacks that will run before/after _each test file_ runs, so they should be performant. Mostly cleanup functions.
|
||||||
|
|
||||||
|
4. It runs the actual tests in the loaded file.
|
||||||
|
|
||||||
|
5. (Currently) Runs `beforeEach` callback that cleans the database before each test file is run.
|
||||||
|
|
||||||
|
6. Runs the next test file, and repeats from step 3.
|
||||||
|
|
||||||
|
## General Notes About Tests
|
||||||
|
|
||||||
|
1. Tests should map to a specific file in the api/src folder.
|
||||||
|
|
||||||
|
e.g.
|
||||||
|
|
||||||
|
- `api/src/models/funding-submission-line-json.ts` maps to `api/tests/models/funding-submission-line-json.test.ts`
|
||||||
|
- `api/src/services/centre-services.ts` maps to `api/tests/services/centre-services.test.ts`
|
||||||
|
|
||||||
|
2. Tests should follow the naming convention `{filename}.test.{extension}`.
|
||||||
|
3. Test file location should be moved if a given file is moved, and deleted if the file under test is deleted.
|
||||||
|
4. A good general pattern for a test is
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("api/src/services/centre-services.ts", () => { // references file under test
|
||||||
|
describe("CentreServices", () => { // references class or model under test
|
||||||
|
describe(".create", () => { // referneces a specific method on the class or model
|
||||||
|
test("creates a new centre in the database", async () => { // descriptive message about the specific behaviour under test
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
5. I'm using a plugin that lets me switch between the test and non-test file, and creates the test file if it does not exist. It's not great, but it mostly works. See <https://marketplace.visualstudio.com/items?itemName=klondikemarlen.create-test-file>
|
||||||
|
|
||||||
|
It requires this config (in your workspace or `.vscode/settings.json`).
|
||||||
|
|
||||||
|
> Note that if this is in your worspace config must be inside the "settings" entry. i.e. `{ "settings": { // these settings } }`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"createTestFile.nameTemplate": "{filename}.test.{extension}",
|
||||||
|
"createTestFile.languages": {
|
||||||
|
"[vue]": {
|
||||||
|
"createTestFile.nameTemplate": "{filename}.test.{extension}.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createTestFile.pathMaps": [
|
||||||
|
{
|
||||||
|
// Other examples
|
||||||
|
// "pathPattern": "/?(.*)",
|
||||||
|
// "testFilePathPattern": "spec/$1"
|
||||||
|
"pathPattern": "(api)/src/?(.*)",
|
||||||
|
"testFilePathPattern": "$1/tests/$2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pathPattern": "(web)/src/?(.*)",
|
||||||
|
"testFilePathPattern": "$1/tests/$2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createTestFile.isTestFileMatchers": [
|
||||||
|
"^(?:test|spec)s?/",
|
||||||
|
"/(?:test|spec)s?/",
|
||||||
|
"/?(?:test|spec)s?_",
|
||||||
|
"/?_(?:test|spec)s?",
|
||||||
|
"/?\\.(?:test|spec)s?",
|
||||||
|
"/?(?:test|spec)s?\\."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { User } from "@/models"
|
||||||
|
|
||||||
|
import { userFactory } from "@/factories"
|
||||||
|
import { mockCurrentUser, request } from "@/support"
|
||||||
|
|
||||||
|
describe("api/src/controllers/current-user-controller.ts", () => {
|
||||||
|
describe("CurrentUserController", () => {
|
||||||
|
describe("#show", () => {
|
||||||
|
test("it returns the policy alongside the user", async () => {
|
||||||
|
// Arrange
|
||||||
|
const currentUser = await userFactory.create({
|
||||||
|
roles: [User.Roles.SYSTEM_ADMIN],
|
||||||
|
})
|
||||||
|
mockCurrentUser(currentUser)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const response = await request().get("/api/current-user")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(response.body.policy).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user