Compare commits
7 Commits
82128e444e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ef52ed8b | |||
| fe02210c34 | |||
| 68f43b9bdf | |||
| 3d8d23ed69 | |||
| 223558d480 | |||
| 24c184a19f | |||
| 7bd0bbe2d8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,7 +4,7 @@
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
# logs # This should be allowed because of the entity called `logs`
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
111
README.md
111
README.md
@@ -1,29 +1,9 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
# Glossary API
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
## Stack
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
- [Nest](https://github.com/nestjs/nest)
|
||||
- TypeORM
|
||||
|
||||
## Project setup
|
||||
|
||||
@@ -31,9 +11,86 @@
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Running local infrastructure
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
# Set env variables, which default to local infrastructure
|
||||
$ . ./set-env
|
||||
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
@@ -87,12 +144,6 @@ Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
|
||||
31
dev_infra/compose.yaml
Normal file
31
dev_infra/compose.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgresql_db:
|
||||
image: postgres:latest
|
||||
container_name: postgresql-db
|
||||
restart: always
|
||||
ports:
|
||||
- "5432:5432" # Default PostgreSQL port
|
||||
environment:
|
||||
POSTGRES_USER: glossary_dev_user
|
||||
POSTGRES_DB: glossary_dev_db
|
||||
POSTGRES_PASSWORD: glossary_dev_password
|
||||
volumes:
|
||||
- postgresql_db_data:/var/lib/postgresql # Docker managed volume
|
||||
|
||||
# pgAdmin4 Web UI for PostgreSQL database
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: pgadmin
|
||||
restart: always
|
||||
ports:
|
||||
- "8888:80" # Expose Web UI to port 8888
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD: postgresql
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin # Docker managed volume
|
||||
|
||||
volumes:
|
||||
postgresql_db_data:
|
||||
pgadmin_data:
|
||||
805
package-lock.json
generated
805
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -17,14 +17,26 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "ts-node ./node_modules/typeorm/cli.js",
|
||||
"db:gen_migration": "ts-node ./node_modules/typeorm/cli.js migration:generate src/migrations/migration -d src/appDataSource.ts",
|
||||
"db:gen_migration_named": "ts-node ./node_modules/typeorm/cli.js migration:generate src/migrations/$npm_config_name -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_prod": "ts-node ./node_modules/typeorm/cli.js migration:run -d dist/appDataSource.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"axios": "^1.13.4",
|
||||
"pg": "^8.18.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
|
||||
15
set-env.sh
Normal file
15
set-env.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
# Databse connection info
|
||||
export DB_TYPE=postgres
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=5432
|
||||
export DB_USERNAME=glossary_dev_user
|
||||
export DB_PASSWORD=glossary_dev_password
|
||||
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
|
||||
@@ -1,10 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { dataSourceOptions } from './appDataSource';
|
||||
import { EntryModule } from './entries/entries.module';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { LogsModule } from './logs/logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigModule.forRoot(),
|
||||
TypeOrmModule.forRoot(dataSourceOptions),
|
||||
EntryModule,
|
||||
UsersModule,
|
||||
LogsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule {
|
||||
constructor(
|
||||
private dataSource: DataSource,
|
||||
private entityManager: EntityManager,
|
||||
) {}
|
||||
}
|
||||
|
||||
27
src/appDataSource.ts
Normal file
27
src/appDataSource.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { Entry } from './entries/entries.entity';
|
||||
import { Initial1770020781006 } from './migrations/1770020781006-initial';
|
||||
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 = {
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT as string, 10) || 5432,
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
entities: [Entry, User, Log],
|
||||
migrations: [
|
||||
Initial1770020781006,
|
||||
AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619,
|
||||
AddUserEntity1770147074119,
|
||||
AddLogEntity1770232943778,
|
||||
],
|
||||
synchronize: false,
|
||||
};
|
||||
|
||||
export const appDataSource = new DataSource(dataSourceOptions);
|
||||
91
src/authentication/authentication.guard.ts
Normal file
91
src/authentication/authentication.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/authentication/authentication.module.ts
Normal file
12
src/authentication/authentication.module.ts
Normal 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 {}
|
||||
76
src/authentication/authentication.service.ts
Normal file
76
src/authentication/authentication.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/entries/entries.controller.spec.ts
Normal file
18
src/entries/entries.controller.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntriesController } from './entries.controller';
|
||||
|
||||
describe('EntriesController', () => {
|
||||
let controller: EntriesController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EntriesController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<EntriesController>(EntriesController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
68
src/entries/entries.controller.ts
Normal file
68
src/entries/entries.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { EntryService } from './entries.service';
|
||||
import { EntryDTO } from './entries.dto';
|
||||
import { Entry } from './entries.entity';
|
||||
import { AuthenticationGuard } from 'src/authentication/authentication.guard';
|
||||
|
||||
@Controller('entries')
|
||||
export class EntriesController {
|
||||
constructor(
|
||||
@Inject(EntryService)
|
||||
private readonly entryService: EntryService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async saveEntry(@Body() entry: EntryDTO): Promise<string | undefined> {
|
||||
return (await this.entryService.save(entry)).uuid;
|
||||
}
|
||||
|
||||
@Put(':uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async updateEntry(
|
||||
@Param('uuid') uuid: string,
|
||||
@Body() entry: EntryDTO,
|
||||
): Promise<string | undefined> {
|
||||
return (await this.entryService.updateByUuid(uuid, entry)).uuid;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthenticationGuard)
|
||||
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(':uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async findOneByUuid(@Param('uuid') uuid: string): Promise<Entry | null> {
|
||||
return await this.entryService.findOneByUuid(uuid);
|
||||
}
|
||||
|
||||
@Delete(':uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async softDelete(@Param('uuid') uuid: string): Promise<void> {
|
||||
await this.entryService.softDeleteByUuid(uuid);
|
||||
}
|
||||
|
||||
@Put('/restore/:uuid')
|
||||
@UseGuards(AuthenticationGuard)
|
||||
async restoreSoftDeleted(@Param('uuid') uuid: string): Promise<Entry | null> {
|
||||
return await this.entryService.restoreDeletedByUuid(uuid);
|
||||
}
|
||||
}
|
||||
8
src/entries/entries.dto.ts
Normal file
8
src/entries/entries.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Entry } from './entries.entity';
|
||||
|
||||
export class EntryDTO implements Partial<Entry> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
sourceText?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
40
src/entries/entries.entity.ts
Normal file
40
src/entries/entries.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Generated,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ name: 'entries' })
|
||||
export class Entry {
|
||||
@Column()
|
||||
@Generated('uuid')
|
||||
uuid?: string;
|
||||
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id?: number;
|
||||
|
||||
@Column({ type: String, default: '' })
|
||||
title?: string;
|
||||
|
||||
@Column({ type: String, default: '' })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: String, default: '' })
|
||||
sourceText?: string;
|
||||
|
||||
@Column({ type: String, default: '' })
|
||||
sourceUrl?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at?: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at?: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deleted_at?: Date;
|
||||
}
|
||||
19
src/entries/entries.module.ts
Normal file
19
src/entries/entries.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Entry } from './entries.entity';
|
||||
import { EntryService } from './entries.service';
|
||||
import { EntriesController } from './entries.controller';
|
||||
import { LogsModule } from 'src/logs/logs.module';
|
||||
import { AuthenticationModule } from 'src/authentication/authentication.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Entry]),
|
||||
LogsModule,
|
||||
AuthenticationModule,
|
||||
],
|
||||
exports: [EntryService],
|
||||
providers: [EntryService],
|
||||
controllers: [EntriesController],
|
||||
})
|
||||
export class EntryModule {}
|
||||
104
src/entries/entries.service.ts
Normal file
104
src/entries/entries.service.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Entry } from './entries.entity';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntryDTO } from './entries.dto';
|
||||
import EntryNotFoundException from './exceptions/entryNotFound.exception';
|
||||
import { LogsService } from 'src/logs/logs.service';
|
||||
|
||||
@Injectable()
|
||||
export class EntryService {
|
||||
constructor(
|
||||
@InjectRepository(Entry)
|
||||
private entryRepository: Repository<Entry>,
|
||||
private readonly logger: LogsService,
|
||||
) {}
|
||||
|
||||
async save(data: EntryDTO): Promise<Entry> {
|
||||
await this.logger.log('Creating entry', 'EntryService');
|
||||
const lEntry = new Entry();
|
||||
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> {
|
||||
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 findOneByUuid(uuid: string): Promise<Entry | null> {
|
||||
await this.logger.log(`Returning entry with UUID ${uuid}`, 'EntryService');
|
||||
return this.entryRepository.findOneByOrFail({ uuid: uuid });
|
||||
}
|
||||
|
||||
async findAll(): Promise<Entry[]> {
|
||||
await this.logger.log('Returning all entries', 'EntryService');
|
||||
return this.entryRepository.find();
|
||||
}
|
||||
|
||||
async findAllAndDeleted(): Promise<Entry[]> {
|
||||
await this.logger.log(
|
||||
'Returning all entries, active and deleted',
|
||||
'EntryService',
|
||||
);
|
||||
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 restoreDeletedByUuid(uuid: string): Promise<Entry | null> {
|
||||
await this.logger.log(`Restoring entry with UUID ${uuid}`, 'EntryService');
|
||||
const restoreResponse = await this.entryRepository.restore({
|
||||
uuid: uuid,
|
||||
});
|
||||
|
||||
if (!restoreResponse.affected) {
|
||||
const exception = new EntryNotFoundException(uuid);
|
||||
await this.logger.error(
|
||||
exception.message,
|
||||
exception.stack,
|
||||
'EntryService',
|
||||
);
|
||||
throw exception;
|
||||
} else {
|
||||
return this.entryRepository.findOneByOrFail({
|
||||
uuid: uuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/entries/exceptions/entryNotFound.exception.ts
Normal file
9
src/entries/exceptions/entryNotFound.exception.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
class EntryNotFoundException extends NotFoundException {
|
||||
constructor(uuid: string) {
|
||||
super(`Entry with UUID ${uuid} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export default EntryNotFoundException;
|
||||
24
src/logs/logs.entity.ts
Normal file
24
src/logs/logs.entity.ts
Normal 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
12
src/logs/logs.module.ts
Normal 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
46
src/logs/logs.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/migrations/1770020781006-initial.ts
Normal file
15
src/migrations/1770020781006-initial.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Initial1770020781006 implements MigrationInterface {
|
||||
name = 'Initial1770020781006';
|
||||
|
||||
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"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "entries"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619 implements MigrationInterface {
|
||||
name = 'AddCreateUpdateDeleteColsAndRenameSourceToSourceText1770067407619';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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(
|
||||
`ALTER TABLE "entries" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "entries" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "entries" ADD "deleted_at" TIMESTAMP`);
|
||||
}
|
||||
|
||||
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 ''`,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/migrations/1770147074119-add_user_entity.ts
Normal file
15
src/migrations/1770147074119-add_user_entity.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
15
src/migrations/1770232943778-add_log_entity.ts
Normal file
15
src/migrations/1770232943778-add_log_entity.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
18
src/users/users.controller.spec.ts
Normal file
18
src/users/users.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
22
src/users/users.controller.ts
Normal file
22
src/users/users.controller.ts
Normal 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
9
src/users/users.dto.ts
Normal 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
43
src/users/users.entity.ts
Normal 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
14
src/users/users.module.ts
Normal 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 {}
|
||||
27
src/users/users.service.ts
Normal file
27
src/users/users.service.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user