Compare commits

..

5 Commits

29 changed files with 839 additions and 234 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@
/build /build
# Logs # Logs
logs # logs # This should be allowed because of the entity called `logs`
*.log *.log
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*

View File

@@ -13,14 +13,82 @@ $ npm install
## Running local infrastructure ## Running local infrastructure
To simplify development, all local infrastructure is handled with Docker. The folder `dev_infra` contains the folloeing: To simplify development, all local infrastructure is handled with Docker. The folder `dev_infra` contains the following:
- `compose.yaml`: A Docker compose file that stands up a Postgres database and a pgAdmin instance to visualize. - `compose.yaml`: A Docker compose file that stands up a Postgres database and a pgAdmin instance to visualize.
To start up the local infrastructure, open a new terminal session, navigate to the `dev_infra` folder, and run the following command:
```bash
# start up the docker instances for the Postgres database and the pgAdmin interface
$ docker compose up
# use 'ctrl + c' on the keyboard to close the Docker view
```
Once finished, use the following command to tear down the local infrastructure:
```bash
# tear down the docker instances
$ docker compose down
```
### Authentication
To test authentication in development, a dev cognito pool was created. Its' important values are included in the `set-env.sh` file. The pool consists of the following users:
- email: jesse.desjardins@ccascoe.org, username: 5c6d4528-5021-704a-27fd-68342d44783b, password: DuckTester25!
An access token will be required to test the routes. A safe way of getting one is as follows:
1. Logging into the default [Cognito Login page](https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com/login/continue?client_id=7ph76km1h1v4vkt4rj9c3s4o6&redirect_uri=https%3A%2F%2Fd84l1y8p4kdic.cloudfront.net&response_type=code&scope=email+openid+phone) using that URL, as it has the `openid` scope included.
2. After logging in, the URL will have a `code` parameter appended at then end. Use that code in the following curl command:
```bash
curl -X POST \
https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=7ph76km1h1v4vkt4rj9c3s4o6" \
-d "code=<enter code here>" \
-d "redirect_uri=https://d84l1y8p4kdic.cloudfront.net"
```
3. The curl command will return, among other things, an `access_token` value. That token will be valid for 1 hour and will work for manual tests of the API if included in all API calls.
#### Alternative ways of retrieving tokens
Using the bolow command will get a token from the AWS CLI, but it will not have the required "openId" scope and will fail authorization in the API:
```bash
aws cognito-idp admin-initiate-auth --user-pool-id "ca-central-1_ea62zDmOC" --client-id "7ph76km1h1v4vkt4rj9c3s4o6" --auth-flow "ADMIN_USER_PASSWORD_AUTH" --auth-parameters "USERNAME=5c6d4528-5021-704a-27fd-68342d44783b,PASSWORD=DuckTester25!"
```
### Database Migrations
Any time a change is made to a `.entity.ts` file, a new migration will need to be generated and added to the `appDataSource.ts` file. This ensures the API is refencing the most up-to-date (and valid!) database schema. These are the migration commands:
```bash
# generate migration
$ npm run db:gen_migration
# generate migration with a custom name
$ npm run db:gen_migration_named --name=<any_name_here>
# show migrations
$ npm run db:show_migrations
# run migrations
$ npm run db:run_migrations
```
**NOTE ON MIGRATIONS IN TYPEORM**: In order for any new migration to be considered in the `db:show_migrations` or `db:run_migrations` commands, they'll need to be added to the `appDataSource.ts` file, under the `migrations: []` option.
## Compile and run the project ## Compile and run the project
```bash ```bash
# Set env variables # Set env variables, which default to local infrastructure
$ . ./set-env $ . ./set-env
# development # development

94
package-lock.json generated
View File

@@ -9,11 +9,13 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.4",
"pg": "^8.18.0", "pg": "^8.18.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
@@ -1354,9 +1356,9 @@
} }
}, },
"node_modules/@isaacs/brace-expansion": { "node_modules/@isaacs/brace-expansion": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2075,6 +2077,17 @@
"@tybys/wasm-util": "^0.10.0" "@tybys/wasm-util": "^0.10.0"
} }
}, },
"node_modules/@nestjs/axios": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz",
"integrity": "sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"axios": "^1.3.1",
"rxjs": "^7.0.0"
}
},
"node_modules/@nestjs/cli": { "node_modules/@nestjs/cli": {
"version": "11.0.16", "version": "11.0.16",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.16.tgz",
@@ -2152,14 +2165,14 @@
} }
}, },
"node_modules/@nestjs/config": { "node_modules/@nestjs/config": {
"version": "4.0.2", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.3.tgz",
"integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==", "integrity": "sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dotenv": "16.4.7", "dotenv": "17.2.3",
"dotenv-expand": "12.0.1", "dotenv-expand": "12.0.3",
"lodash": "4.17.21" "lodash": "4.17.23"
}, },
"peerDependencies": { "peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/common": "^10.0.0 || ^11.0.0",
@@ -2167,9 +2180,9 @@
} }
}, },
"node_modules/@nestjs/config/node_modules/dotenv": { "node_modules/@nestjs/config/node_modules/dotenv": {
"version": "16.4.7", "version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2178,12 +2191,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/@nestjs/config/node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/@nestjs/core": { "node_modules/@nestjs/core": {
"version": "11.1.12", "version": "11.1.12",
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.12.tgz", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.12.tgz",
@@ -3792,7 +3799,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
@@ -3810,6 +3816,17 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "30.2.0", "version": "30.2.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz",
@@ -4417,7 +4434,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@@ -4702,7 +4718,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@@ -4761,9 +4776,9 @@
} }
}, },
"node_modules/dotenv-expand": { "node_modules/dotenv-expand": {
"version": "12.0.1", "version": "12.0.3",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz",
"integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"dotenv": "^16.4.5" "dotenv": "^16.4.5"
@@ -4901,7 +4916,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@@ -5459,6 +5473,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5522,7 +5556,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@@ -5539,7 +5572,6 @@
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 0.6"
@@ -5549,7 +5581,6 @@
"version": "2.1.35", "version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"mime-db": "1.52.0" "mime-db": "1.52.0"
@@ -7228,7 +7259,6 @@
"version": "4.17.23", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
@@ -8254,6 +8284,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -24,15 +24,15 @@
"db:show_migrations": "ts-node ./node_modules/typeorm/cli.js migration:show -d src/appDataSource.ts", "db:show_migrations": "ts-node ./node_modules/typeorm/cli.js migration:show -d src/appDataSource.ts",
"db:run_migrations": "ts-node ./node_modules/typeorm/cli.js migration:run -d src/appDataSource.ts", "db:run_migrations": "ts-node ./node_modules/typeorm/cli.js migration:run -d src/appDataSource.ts",
"db:run_migrations_prod": "ts-node ./node_modules/typeorm/cli.js migration:run -d dist/appDataSource.ts" "db:run_migrations_prod": "ts-node ./node_modules/typeorm/cli.js migration:run -d dist/appDataSource.ts"
}, },
"dependencies": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"axios": "^1.13.4",
"pg": "^8.18.0", "pg": "^8.18.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",

View File

@@ -1,3 +1,4 @@
# Databse connection info
export DB_TYPE=postgres export DB_TYPE=postgres
export DB_HOST=localhost export DB_HOST=localhost
export DB_PORT=5432 export DB_PORT=5432
@@ -5,4 +6,10 @@ export DB_USERNAME=glossary_dev_user
export DB_PASSWORD=glossary_dev_password export DB_PASSWORD=glossary_dev_password
export DB_NAME=glossary_dev_db export DB_NAME=glossary_dev_db
# Authentication server info
export AUTH_USER_POOL_ID=ca-central-1_ea62zDmOC
export AUTH_REGION=ca-central-1
export AUTH_BASE_URL=https://ca-central-1ea62zdmoc.auth.ca-central-1.amazoncognito.com
export AUTH_APP_CLIENT_ID=7ph76km1h1v4vkt4rj9c3s4o6
npm run db:run_migrations npm run db:run_migrations

View File

@@ -7,12 +7,16 @@ import { AppService } from './app.service';
import { dataSourceOptions } from './appDataSource'; import { dataSourceOptions } from './appDataSource';
import { EntryModule } from './entries/entries.module'; import { EntryModule } from './entries/entries.module';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { UsersModule } from './users/users.module';
import { LogsModule } from './logs/logs.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot(), ConfigModule.forRoot(),
TypeOrmModule.forRoot(dataSourceOptions), TypeOrmModule.forRoot(dataSourceOptions),
EntryModule EntryModule,
UsersModule,
LogsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -1,18 +1,27 @@
import { DataSource, DataSourceOptions } from "typeorm"; import { DataSource, DataSourceOptions } from 'typeorm';
import { Entry } from "./entries/entries.entity"; import { Entry } from './entries/entries.entity';
import { Initial1770020781006 } from "./migrations/1770020781006-initial"; import { Initial1770020781006 } from './migrations/1770020781006-initial';
import { AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619 } from "./migrations/1770067407619-add_create_update_delete_cols_and_rename_source_to_sourceText"; import { AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619 } from './migrations/1770067407619-add_create_update_delete_cols_and_rename_source_to_sourceText';
import { User } from './users/users.entity';
import { AddUserEntity1770147074119 } from './migrations/1770147074119-add_user_entity';
import { Log } from './logs/logs.entity';
import { AddLogEntity1770232943778 } from './migrations/1770232943778-add_log_entity';
export const dataSourceOptions: DataSourceOptions = { export const dataSourceOptions: DataSourceOptions = {
type: "postgres", type: 'postgres',
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT as string, 10) || 5432, port: parseInt(process.env.DB_PORT as string, 10) || 5432,
username: process.env.DB_USERNAME, username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
entities: [Entry], entities: [Entry, User, Log],
migrations: [Initial1770020781006, AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619], migrations: [
synchronize: false, Initial1770020781006,
} AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619,
AddUserEntity1770147074119,
AddLogEntity1770232943778,
],
synchronize: false,
};
export const appDataSource = new DataSource(dataSourceOptions) export const appDataSource = new DataSource(dataSourceOptions);

View File

@@ -0,0 +1,91 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { AuthenticationService } from './authentication.service';
import { LogsService } from 'src/logs/logs.service';
import { Request } from 'express';
@Injectable()
export class AuthenticationGuard implements CanActivate {
constructor(
private readonly authenticationService: AuthenticationService,
private readonly logger: LogsService,
) {}
/**
* Used by the guard attached to a controller's route. Determines wether the current request is allowed to proceed.
*
* @param context
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
const exception = new UnauthorizedException(
'Authrorization: Bearer <token> header invalid',
);
this.logger.error(
exception.message,
exception.stack,
'AuthenticationGuard',
);
throw exception;
}
try {
// store the user on the request object if we want to retrieve it from the controllers
request['user'] =
await this.authenticationService.authenticateWithCognito(token);
} catch {
const exception = new UnauthorizedException();
this.logger.error(
exception.message,
exception.stack,
'AuthenticationGuard',
);
throw exception;
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const authHeader: string | null = '';
const [type, token] = request.headers.authorization?.split(' ') ?? [];
if (!type) {
const exception = new HttpException(
'Authrorization: Bearer <token> header missing',
HttpStatus.UNAUTHORIZED,
);
this.logger.error(
exception.message,
exception.stack,
'AuthenticationGuard',
);
throw exception;
}
if (type !== 'Bearer') {
const exception = new HttpException(
'Authrorization: Bearer <token> header invalid',
HttpStatus.UNAUTHORIZED,
);
this.logger.error(
exception.message,
exception.stack,
'AuthenticationGuard',
);
throw exception;
}
return type === 'Bearer' ? token : undefined;
// const [type, token] = request.headers.authorization?.split(' ') ?? [];
// return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,12 @@
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { AuthenticationGuard } from './authentication.guard';
import { AuthenticationService } from './authentication.service';
import { LogsModule } from 'src/logs/logs.module';
@Module({
imports: [HttpModule, LogsModule],
providers: [AuthenticationGuard, AuthenticationService],
exports: [AuthenticationService],
})
export class AuthenticationModule {}

View File

@@ -0,0 +1,76 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { lastValueFrom } from 'rxjs';
import { LogsService } from 'src/logs/logs.service';
/**
* Error thrown when an authentication check has failed
*/
export class AuthenticationError extends Error {}
/**
* Simple user class that holds key info for reference later
*/
export class AuthUser {
/**
* Identifying user ID (i.e. "sub" field in AWS Cognito)
*/
id: string;
/**
* User's customized username
*/
username: string;
}
@Injectable()
export class AuthenticationService {
constructor(
private httpService: HttpService,
private readonly logger: LogsService,
) {
this.baseURL = process.env.AUTH_BASE_URL;
}
/**
* The base URL of the service being used for authentication
*/
private readonly baseURL: string | undefined;
/**
* Calls the OpenID Connect UserInfo endpoint.
*
* If the call succeeds, the token is valid and the user info is returned in the response.
* If the call fails, the token is either invalid or expired.
*
* @param accessToken Access token passed into the HTTP call for authentication
*/
async authenticateWithCognito(accessToken: string): Promise<AuthUser> {
const authUrl = `${this.baseURL}/oauth2/userInfo`;
try {
const response = this.httpService.get(authUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const responseData = await lastValueFrom(response);
await this.logger.log(
`Access attempted with resolved user: ${responseData.data.sub}`,
'AuthenticationService',
);
return {
id: responseData?.data.sub ?? '',
username: responseData?.data.preferred_username ?? '',
};
} catch (err: any) {
const authErr = new AuthenticationError(err.message);
await this.logger.error(
authErr.message,
authErr.stack,
'AuthenticationService',
);
throw authErr;
}
}
}

View File

@@ -1,50 +1,68 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Put } from '@nestjs/common'; import {
Body,
Controller,
Delete,
Get,
Inject,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { EntryService } from './entries.service'; import { EntryService } from './entries.service';
import { EntryDTO } from './entries.dto'; import { EntryDTO } from './entries.dto';
import { Entry } from './entries.entity'; import { Entry } from './entries.entity';
import { AuthenticationGuard } from 'src/authentication/authentication.guard';
@Controller('entries') @Controller('entries')
export class EntriesController { export class EntriesController {
constructor( constructor(
@Inject(EntryService) @Inject(EntryService)
private readonly entryService: EntryService private readonly entryService: EntryService,
) {} ) {}
@Post()
@UseGuards(AuthenticationGuard)
async saveEntry(@Body() entry: EntryDTO): Promise<string | undefined> {
return (await this.entryService.save(entry)).uuid;
}
@Post() @Put(':uuid')
async saveEntry(@Body() entry: EntryDTO): Promise<string | undefined> { @UseGuards(AuthenticationGuard)
return (await this.entryService.save(entry)).uuid async updateEntry(
} @Param('uuid') uuid: string,
@Body() entry: EntryDTO,
): Promise<string | undefined> {
return (await this.entryService.updateByUuid(uuid, entry)).uuid;
}
@Put(":uuid") @Get()
async updateEntry(@Param("uuid") uuid: string, @Body() entry: EntryDTO): Promise<string | undefined> { @UseGuards(AuthenticationGuard)
return (await this.entryService.updateByUuid(uuid, entry)).uuid async findAll(): Promise<Entry[]> {
} const entries = await this.entryService.findAll();
return entries.sort((a, b) => {
return (
new Date(b.created_at as Date).getTime() -
new Date(a.created_at as Date).getTime()
);
});
}
@Get() @Get(':uuid')
async findAll(): Promise<Entry[]> @UseGuards(AuthenticationGuard)
{ async findOneByUuid(@Param('uuid') uuid: string): Promise<Entry | null> {
const entries = await this.entryService.findAll() return await this.entryService.findOneByUuid(uuid);
return entries.sort((a, b) => { }
return (
new Date(b.created_at as Date).getTime() - new Date(a.created_at as Date).getTime()
)
})
}
@Get(":uuid") @Delete(':uuid')
async findOneByUuid(@Param("uuid") uuid: string): Promise<Entry | null> { @UseGuards(AuthenticationGuard)
return await this.entryService.findOneByUuid(uuid) async softDelete(@Param('uuid') uuid: string): Promise<void> {
} await this.entryService.softDeleteByUuid(uuid);
}
@Delete(":uuid") @Put('/restore/:uuid')
async softDelete(@Param("uuid") uuid: string): Promise<void> @UseGuards(AuthenticationGuard)
{ async restoreSoftDeleted(@Param('uuid') uuid: string): Promise<Entry | null> {
await this.entryService.softDeleteByUuid(uuid) return await this.entryService.restoreDeletedByUuid(uuid);
} }
@Put("/restore/:uuid")
async restoreSoftDeleted(@Param("uuid") uuid: string): Promise<Entry | null> {
return await this.entryService.restoreDeletedByUuid(uuid)
}
} }

View File

@@ -1,8 +1,8 @@
import { Entry } from "./entries.entity"; import { Entry } from './entries.entity';
export class EntryDTO implements Partial<Entry> { export class EntryDTO implements Partial<Entry> {
title?: string; title?: string;
description?: string; description?: string;
sourceText?: string; sourceText?: string;
sourceUrl?: string; sourceUrl?: string;
} }

View File

@@ -1,32 +1,40 @@
import { Column, CreateDateColumn, DeleteDateColumn, Entity, Generated, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Generated,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: "entries"}) @Entity({ name: 'entries' })
export class Entry { export class Entry {
@Column() @Column()
@Generated("uuid") @Generated('uuid')
uuid?: string; uuid?: string;
@PrimaryGeneratedColumn("increment") @PrimaryGeneratedColumn('increment')
id?: number; id?: number;
@Column({ type: String, default: "" }) @Column({ type: String, default: '' })
title?: string; title?: string;
@Column({ type: String, default: "" }) @Column({ type: String, default: '' })
description?: string; description?: string;
@Column({ type: String, default: "" }) @Column({ type: String, default: '' })
sourceText?: string; sourceText?: string;
@Column({ type: String, default: "" }) @Column({ type: String, default: '' })
sourceUrl?: string; sourceUrl?: string;
@CreateDateColumn() @CreateDateColumn()
created_at?: Date; created_at?: Date;
@UpdateDateColumn() @UpdateDateColumn()
updated_at?: Date; updated_at?: Date;
@DeleteDateColumn() @DeleteDateColumn()
deleted_at?: Date; deleted_at?: Date;
} }

View File

@@ -1,15 +1,19 @@
import { Module } from "@nestjs/common"; import { Module } from '@nestjs/common';
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from '@nestjs/typeorm';
import { Entry } from "./entries.entity"; import { Entry } from './entries.entity';
import { EntryService } from "./entries.service"; import { EntryService } from './entries.service';
import { EntriesController } from "./entries.controller"; import { EntriesController } from './entries.controller';
import { LogsModule } from 'src/logs/logs.module';
import { AuthenticationModule } from 'src/authentication/authentication.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Entry]), TypeOrmModule.forFeature([Entry]),
], LogsModule,
exports: [EntryService], AuthenticationModule,
providers: [EntryService], ],
controllers: [EntriesController], exports: [EntryService],
providers: [EntryService],
controllers: [EntriesController],
}) })
export class EntryModule {} export class EntryModule {}

View File

@@ -1,85 +1,104 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from '@nestjs/typeorm';
import { Entry } from "./entries.entity"; import { Entry } from './entries.entity';
import { Repository } from "typeorm"; import { Repository } from 'typeorm';
import { EntryDTO } from "./entries.dto"; import { EntryDTO } from './entries.dto';
import EntryNotFoundException from "./exceptions/entryNotFound.exception"; import EntryNotFoundException from './exceptions/entryNotFound.exception';
import { LogsService } from 'src/logs/logs.service';
@Injectable() @Injectable()
export class EntryService { export class EntryService {
constructor( constructor(
@InjectRepository(Entry) @InjectRepository(Entry)
private entryRepository: Repository<Entry> private entryRepository: Repository<Entry>,
) {} private readonly logger: LogsService,
private readonly logger = new Logger(EntryService.name) ) {}
async save(data: EntryDTO): Promise<Entry> { async save(data: EntryDTO): Promise<Entry> {
this.logger.log("Creating entry") await this.logger.log('Creating entry', 'EntryService');
const lEntry = new Entry(); const lEntry = new Entry();
lEntry.description = data.description lEntry.description = data.description;
lEntry.sourceText = data.sourceText lEntry.sourceText = data.sourceText;
lEntry.sourceUrl = data.sourceUrl lEntry.sourceUrl = data.sourceUrl;
lEntry.title = data.title lEntry.title = data.title;
await this.entryRepository.save(lEntry) await this.entryRepository.save(lEntry);
return lEntry return lEntry;
}
async updateByUuid(uuid: string, data: EntryDTO): Promise<Entry> {
await this.logger.log(`Updating entry with UUID ${uuid}`, 'EntryService');
const oldEntry = await this.entryRepository.findOneBy({ uuid: uuid });
if (!oldEntry) {
throw new EntryNotFoundException(uuid);
} else {
const lEntry = new Entry();
// `id` is the primary key
lEntry.id = oldEntry.id;
// These options can be updated
lEntry.description = data.description;
lEntry.sourceText = data.sourceText;
lEntry.sourceUrl = data.sourceUrl;
lEntry.title = data.title;
await this.entryRepository.save(lEntry);
return lEntry;
} }
}
async updateByUuid(uuid: string, data: EntryDTO): Promise<Entry> { async findOneByUuid(uuid: string): Promise<Entry | null> {
this.logger.log(`Updating entry with UUID ${uuid}`) await this.logger.log(`Returning entry with UUID ${uuid}`, 'EntryService');
const oldEntry = await this.entryRepository.findOneBy({ uuid: uuid}) return this.entryRepository.findOneByOrFail({ uuid: uuid });
if (!oldEntry) { }
throw new EntryNotFoundException(uuid)
} else { async findAll(): Promise<Entry[]> {
const lEntry = new Entry(); await this.logger.log('Returning all entries', 'EntryService');
// `id` is the primary key return this.entryRepository.find();
lEntry.id = oldEntry.id }
// These options can be updated
lEntry.description = data.description async findAllAndDeleted(): Promise<Entry[]> {
lEntry.sourceText = data.sourceText await this.logger.log(
lEntry.sourceUrl = data.sourceUrl 'Returning all entries, active and deleted',
lEntry.title = data.title 'EntryService',
await this.entryRepository.save(lEntry) );
return lEntry return this.entryRepository.find({ withDeleted: true });
} }
async softDeleteByUuid(uuid: string): Promise<void> {
await this.logger.log(
`Soft deleting entry with UUID ${uuid}`,
'EntryService',
);
const deleteResponse = await this.entryRepository.softDelete({
uuid: uuid,
});
if (!deleteResponse.affected) {
const exception = new EntryNotFoundException(uuid);
await this.logger.error(
exception.message,
exception.stack,
'EntryService',
);
throw exception;
} }
}
async findOneByUuid(uuid: string): Promise<Entry | null> { async restoreDeletedByUuid(uuid: string): Promise<Entry | null> {
this.logger.log(`Returning entry with UUID ${uuid}`) await this.logger.log(`Restoring entry with UUID ${uuid}`, 'EntryService');
return this.entryRepository.findOneByOrFail({uuid: uuid}) const restoreResponse = await this.entryRepository.restore({
} uuid: uuid,
});
async findAll(): Promise<Entry[]> {
this.logger.log("Returning all entries") if (!restoreResponse.affected) {
return this.entryRepository.find() const exception = new EntryNotFoundException(uuid);
} await this.logger.error(
exception.message,
async findAllAndDeleted(): Promise<Entry[]> { exception.stack,
this.logger.log("Returning all entries, active and deleted") 'EntryService',
return this.entryRepository.find({ withDeleted: true }) );
} throw exception;
} else {
async softDeleteByUuid(uuid: string): Promise<void> { return this.entryRepository.findOneByOrFail({
this.logger.log(`Soft deleting entry with UUID ${uuid}`) uuid: uuid,
const deleteResponse = await this.entryRepository.softDelete({ });
uuid: uuid
})
if (!deleteResponse.affected) {
throw new EntryNotFoundException(uuid)
}
}
async restoreDeletedByUuid(uuid: string): Promise<Entry | null> {
this.logger.log(`Restoring entry with UUID ${uuid}`)
const restoreResponse = await this.entryRepository.restore({
uuid: uuid
})
if (!restoreResponse.affected) {
throw new EntryNotFoundException(uuid)
} else {
return this.entryRepository.findOneByOrFail({
uuid: uuid
})
}
} }
}
} }

View File

@@ -1,9 +1,9 @@
import { NotFoundException } from "@nestjs/common"; import { NotFoundException } from '@nestjs/common';
class EntryNotFoundException extends NotFoundException { class EntryNotFoundException extends NotFoundException {
constructor(uuid: string) { constructor(uuid: string) {
super(`Entry with UUID ${uuid} not found`) super(`Entry with UUID ${uuid} not found`);
} }
} }
export default EntryNotFoundException export default EntryNotFoundException;

24
src/logs/logs.entity.ts Normal file
View File

@@ -0,0 +1,24 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
} from 'typeorm';
@Entity({ name: 'logs' })
export class Log {
@PrimaryGeneratedColumn()
id: number;
@Column()
level: string; // 'log', 'error', 'warn', etc.
@Column({ type: String })
message: string;
@Column({ type: String, nullable: true })
context?: string;
@CreateDateColumn()
timestamp: Date;
}

12
src/logs/logs.module.ts Normal file
View File

@@ -0,0 +1,12 @@
// src/logs/logs.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Log } from './logs.entity';
import { LogsService } from './logs.service';
@Module({
imports: [TypeOrmModule.forFeature([Log])],
exports: [LogsService],
providers: [LogsService],
})
export class LogsModule {}

46
src/logs/logs.service.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Injectable, Logger, LoggerService } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Log } from './logs.entity';
@Injectable()
export class LogsService extends Logger implements LoggerService {
constructor(
@InjectRepository(Log)
private readonly logRepository: Repository<Log>,
) {
super();
}
async log(message: string, context?: string) {
// output to console using default Logger
super.log(message, context);
// Save in DB using TypeORM
await this.saveLog('log', message, context);
}
async error(message: string, stack?: string, context?: string) {
super.error(message, stack, context);
await this.saveLog('error', `${message} - ${stack || ''}`, context);
}
async warn(message: string, context?: string) {
super.warn(message, context);
await this.saveLog('warn', message, context);
}
async debug(message: string, context?: string) {
super.debug(message, context);
await this.saveLog('debug', message, context);
}
private async saveLog(level: string, message: string, context?: string) {
try {
const log = this.logRepository.create({ level, message, context });
await this.logRepository.save(log);
} catch (err) {
// maybe have a backup log (local file?) to catch these
console.error('Failed to save log to database:', err);
}
}
}

View File

@@ -1,14 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class Initial1770020781006 implements MigrationInterface { export class Initial1770020781006 implements MigrationInterface {
name = 'Initial1770020781006' name = 'Initial1770020781006';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "entries" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" SERIAL NOT NULL, "title" character varying NOT NULL DEFAULT '', "description" character varying NOT NULL DEFAULT '', "source" character varying NOT NULL DEFAULT '', "sourceUrl" character varying NOT NULL DEFAULT '', CONSTRAINT "PK_8640855ae82083455cbb806173d" PRIMARY KEY ("id"))`); await queryRunner.query(
} `CREATE TABLE "entries" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" SERIAL NOT NULL, "title" character varying NOT NULL DEFAULT '', "description" character varying NOT NULL DEFAULT '', "source" character varying NOT NULL DEFAULT '', "sourceUrl" character varying NOT NULL DEFAULT '', CONSTRAINT "PK_8640855ae82083455cbb806173d" PRIMARY KEY ("id"))`,
);
public async down(queryRunner: QueryRunner): Promise<void> { }
await queryRunner.query(`DROP TABLE "entries"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "entries"`);
}
} }

View File

@@ -1,22 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619 implements MigrationInterface { export class AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619 implements MigrationInterface {
name = 'AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619' name = 'AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "source"`); await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "source"`);
await queryRunner.query(`ALTER TABLE "entries" ADD "sourceText" character varying NOT NULL DEFAULT ''`); await queryRunner.query(
await queryRunner.query(`ALTER TABLE "entries" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`); `ALTER TABLE "entries" ADD "sourceText" character varying NOT NULL DEFAULT ''`,
await queryRunner.query(`ALTER TABLE "entries" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`); );
await queryRunner.query(`ALTER TABLE "entries" ADD "deleted_at" TIMESTAMP`); await queryRunner.query(
} `ALTER TABLE "entries" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "deleted_at"`); `ALTER TABLE "entries" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "updated_at"`); );
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "created_at"`); await queryRunner.query(`ALTER TABLE "entries" ADD "deleted_at" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "sourceText"`); }
await queryRunner.query(`ALTER TABLE "entries" ADD "source" character varying NOT NULL DEFAULT ''`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "deleted_at"`);
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "updated_at"`);
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "created_at"`);
await queryRunner.query(`ALTER TABLE "entries" DROP COLUMN "sourceText"`);
await queryRunner.query(
`ALTER TABLE "entries" ADD "source" character varying NOT NULL DEFAULT ''`,
);
}
} }

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserEntity1770147074119 implements MigrationInterface {
name = 'AddUserEntity1770147074119';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "users" ("uuid" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" SERIAL NOT NULL, "username" character varying NOT NULL DEFAULT '', "email" character varying NOT NULL DEFAULT '', "firstName" character varying NOT NULL DEFAULT '', "lastName" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "users"`);
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLogEntity1770232943778 implements MigrationInterface {
name = 'AddLogEntity1770232943778';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "logs" ("id" SERIAL NOT NULL, "level" character varying NOT NULL, "message" character varying NOT NULL, "context" character varying, "timestamp" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_fb1b805f2f7795de79fa69340ba" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "logs"`);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
describe('UsersController', () => {
let controller: UsersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,22 @@
import { Controller, Inject } from '@nestjs/common';
import { UserService } from './users.service';
@Controller('users')
export class UsersController {
constructor(
@Inject(UserService)
private readonly userService: UserService,
) {}
// TODO: POST: save (create new) user
// TODO: PUT: update user by UUID
// TODO: GET: find all users
// TODO: GET: find user by UUID
// TODO: DELETE: soft delete user
// TODO: PUT: restore soft deleted user
}

9
src/users/users.dto.ts Normal file
View File

@@ -0,0 +1,9 @@
import { User } from './users.entity';
export class UserDTO implements Partial<User> {
email?: string;
firstName?: string;
isAdmin?: boolean;
lastName?: string;
username?: string;
}

43
src/users/users.entity.ts Normal file
View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Generated,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'users' })
export class User {
@Column()
@Generated('uuid')
uuid?: string;
@PrimaryGeneratedColumn('increment')
id?: number;
@Column({ type: String, default: '' })
username?: string;
@Column({ type: String, default: '' })
email?: string;
@Column({ type: String, default: '' })
firstName?: string;
@Column({ type: String, default: '' })
lastName?: string;
@Column({ type: Boolean, default: false })
isAdmin?: boolean;
@CreateDateColumn()
created_at?: Date;
@UpdateDateColumn()
updated_at?: Date;
@DeleteDateColumn()
deleted_at?: Date;
}

14
src/users/users.module.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users.entity';
import { UserService } from './users.service';
import { UsersController } from './users.controller';
import { AuthenticationModule } from 'src/authentication/authentication.module';
@Module({
imports: [TypeOrmModule.forFeature([User]), AuthenticationModule],
exports: [UserService],
providers: [UserService],
controllers: [UsersController],
})
export class UsersModule {}

View File

@@ -0,0 +1,27 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './users.entity';
import { Repository } from 'typeorm';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
private readonly logger = new Logger(UserService.name);
// TODO: save (create new)
// TODO: update by UUID
// TODO: find one by UUID
// TODO: find all
// TODO: find all and deleted
// TODO: soft delete
// TODO: restore soft deleted by UUID
}