Compare commits

...

7 Commits

31 changed files with 1653 additions and 111 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*

111
README.md
View File

@@ -1,29 +1,9 @@
<p align="center"> # Glossary API
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 ## Stack
[circleci-url]: https://circleci.com/gh/nestjs/nest
<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> - [Nest](https://github.com/nestjs/nest)
<p align="center"> - TypeORM
<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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup ## Project setup
@@ -31,9 +11,86 @@
$ npm install $ 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 ## Compile and run the project
```bash ```bash
# Set env variables, which default to local infrastructure
$ . ./set-env
# development # development
$ npm run start $ 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). 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 ## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

31
dev_infra/compose.yaml Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -17,14 +17,26 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "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": { "dependencies": {
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@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",
"axios": "^1.13.4",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1",
"typeorm": "^0.3.28"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",

15
set-env.sh Normal file
View 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

View File

@@ -1,10 +1,29 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; 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({ @Module({
imports: [], imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot(dataSourceOptions),
EntryModule,
UsersModule,
LogsModule,
],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })
export class AppModule {} export class AppModule {
constructor(
private dataSource: DataSource,
private entityManager: EntityManager,
) {}
}

27
src/appDataSource.ts Normal file
View 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);

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

@@ -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();
});
});

View 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);
}
}

View File

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

View 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;
}

View 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 {}

View 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,
});
}
}
}

View 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
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

@@ -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"`);
}
}

View File

@@ -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 ''`,
);
}
}

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
}