diff --git a/.env.example b/.env.example index 40612a5..a59e70d 100644 --- a/.env.example +++ b/.env.example @@ -1,38 +1,40 @@ -NODE_ENV = development -PORT = 3001 -TZ=Europe/Amsterdam - -TYPEORM_CONNECTION = mysql -TYPEORM_HOST = localhost -TYPEORM_PORT = 5432 -TYPEORM_DATABASE = crm -TYPEORM_USERNAME = parelpracht -TYPEORM_PASSWORD = -TYPEORM_SYNCHRONIZE = true -TYPEORM_LOGGING = false -TYPEORM_MIGRATIONS = dist/migration/**/*.js -TYPEORM_SUBSCRIBERS = dist/subscriber/**/*.js - -SESSION_SECRET = secret - -MAIL_HOST = -MAIL_PORT = 465 -MAIL_USER = -MAIL_PASSWORD = -MAIL_FROM = - -SERVER_HOST = http://localhost:3000 - -# If these environment variables are left empty, LDAP will be disabled -LDAP_URL = -LDAP_BINDDN = -LDAP_BINDCREDENTIALS = -LDAP_SEARCHBASE = -LDAP_SEARCHFILTER = - -# The following environment variables are used by the DirectMail plugin -# This is optional and thus these variables can be removed (if not used) -DIRECTMAIL_URL= -DIRECTMAIL_USERNAME=parelpracht -DIRECTMAIL_PASSWORD= -DIRECTMAIL_PRODUCT_ID= +NODE_ENV = development +PORT = 3001 +TZ=Europe/Amsterdam + +TYPEORM_CONNECTION = mysql +TYPEORM_HOST = 127.0.0.1 +TYPEORM_PORT = 3306 +TYPEORM_DATABASE = parelpracht +TYPEORM_USERNAME = root +TYPEORM_PASSWORD = parelpracht +TYPEORM_SYNCHRONIZE = true +TYPEORM_LOGGING = false +TYPEORM_MIGRATIONS = dist/migration/**/*.js +TYPEORM_SUBSCRIBERS = dist/subscriber/**/*.js +TYPEORM_SSL_ENABLED = false +TYPEORM_SSL_CACERTS = /etc/ssl/certs/ca-certificates.crt + +SESSION_SECRET = secret + +MAIL_HOST = +MAIL_PORT = 465 +MAIL_USER = +MAIL_PASSWORD = +MAIL_FROM = + +SERVER_HOST = http://localhost:3000 + +# If these environment variables are left empty, LDAP will be disabled +LDAP_URL = +LDAP_BINDDN = +LDAP_BINDCREDENTIALS = +LDAP_SEARCHBASE = +LDAP_SEARCHFILTER = + +# The following environment variables are used by the DirectMail plugin +# This is optional and thus these variables can be removed (if not used) +DIRECTMAIL_URL= +DIRECTMAIL_USERNAME=parelpracht +DIRECTMAIL_PASSWORD= +DIRECTMAIL_PRODUCT_ID= diff --git a/.eslintrc.js b/.eslintrc.js index dc7b769..15d3d4e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { plugins: ['@typescript-eslint', 'import'], extends: ['airbnb-typescript/base'], rules: { - 'linebreak-style': ['error', 'windows'], + 'linebreak-style': ['error', 'unix'], 'lines-between-class-members': [ 'error', 'always', @@ -20,6 +20,7 @@ module.exports = { 'arrow-body-style': 'off', 'import/no-cycle': 'off', 'indent': 'off', + '@typescript-eslint/no-redeclare': 'off', '@typescript-eslint/indent': [ 'error', 2, diff --git a/README.md b/README.md index 98fe237..716525d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,48 @@ -

- -
- ParelPracht -

- -ParelPracht is the successor of Goudglans, the custom Customer Relation Management system of Study Association GEWIS. -This new system is built during the second lockdown of the corona pandemic. -Its main goal is to automate tedious tasks and to keep a clear and concise overview of the current collaborations. -This is achieved by creating nice structured insights tables and graphs and automating the generation of contracts, proposals and invoices. - -This is the back-end of ParelPracht. [The front-end can be found here](https://github.com/GEWIS/parelpracht-client). - -## Installation -1. Clone the repository. -2. Run `npm install`. -3. Copy `.env.example` to `.env` and fill / replace the keys with their corresponding values. Note that the email-keys - are important to be able to install the application (see step 5). -4. Run `npm run dev`. This runs the client in development mode. Node will bind to port `3001`. You can find the API - documentation at [http://localhost:3001/api/swagger-ui/](http://localhost:3001/api/swagger-ui/). -5. Make a POST-request to `/v1/setup` with your credentials. The required payload can be found in the Swagger - documentation. This request will create a local administrator account with the given credentials. You will receive an - email (on the given address via the given mail server at step 3) to set your password. -6. In the `VAT` table, add the desired VAT categories and percentages. - -You can also build the application with `npm run build`. This puts a production build in the `./build` directory. - -## Deployment -1. Clone the repository in a folder called `parelpracht-client` and clone the backend repository in a folder called `parelpracht-server`. Make sure that both folders are in the same parent folder. -2. Change the image locations to the correct locations in `docker-compose.yml` (for both the frontend and backend). -3. Fill in the correct (environment) variables in `docker-compose.yml`. -4. Run `docker-compose` in `./parelpracht-client`. - -## Copyright - -Copyright © 2022 The 39th board of GEWIS - Some rights reserved. Created by Roy Kakkenberg, Koen de Nooij, Jealy van den -Aker, Max Opperman, Wouter van der Heijden en Irne Verwijst. You can use our software freely within the limits of -our license. However, we worked very hard on this project and invested a lot of time in it, so we ask you to leave our -copyright mark in place when modifying our software. Of course, you are free to add your own. - -## License -[GNU AGPLv3](./LICENSE) - - +

+ +
+ ParelPracht +

+ +ParelPracht is the successor of Goudglans, the custom Customer Relation +Management system of Study Association GEWIS. This new system is built during +the second lockdown of the corona pandemic. Its main goal is to automate tedious +tasks and to keep a clear and concise overview of the current collaborations. +This is achieved by creating nice structured insights tables and graphs and +automating the generation of contracts, proposals and invoices. + +This is the back-end of ParelPracht. The front-end can be found [here](https://github.com/GEWIS/parelpracht-client). + +## Development +1. Clone the repository with `git clone git@github.com:GEWIS/parelpracht-server` +2. Install the dependencies with `npm install`. +3. Copy `.env.example` to `.env` and add the remaining environment variables. +4. Start the application with `npm run dev` + +It is suggested to use a local MariaDB instance. If you do not have a local +instance, you can use the docker compose file: `docker compose -f +docker-compose-mariadb.yaml up -d`. The environment variables in the +`.env.example` are adjusted to use this container configuration. + +## Setup +When running the application, you will first need to create a superuser. This is +done with the `/setup` endpoint. + +1. Go to the [swagger docs](http://localhost:3001/api/swagger-ui/). +2. Navigate to `/setup` endpoint, and fill out the data for the request. +3. Check the console for the confirmation link. + +Note: the confirmation link will only be logged in development mode. In +production, an actual mail will be send with the confirmation link to the +indicated email address. + +## Copyright +Copyright © 2022 The 39th board of GEWIS - Some rights reserved. Created by Roy +Kakkenberg, Koen de Nooij, Jealy van den Aker, Max Opperman, Wouter van der +Heijden en Irne Verwijst. You can use our software freely within the limits of +our license. However, we worked very hard on this project and invested a lot of +time in it, so we ask you to leave our copyright mark in place when modifying +our software. Of course, you are free to add your own. + +## License +[GNU AGPLv3](./LICENSE) diff --git a/docker-compose-mariadb.yaml b/docker-compose-mariadb.yaml new file mode 100644 index 0000000..891bb0a --- /dev/null +++ b/docker-compose-mariadb.yaml @@ -0,0 +1,10 @@ +services: + mariadb: + image: mariadb:latest + container_name: mariadb + environment: + MARIADB_ROOT_PASSWORD: parelpracht + MARIADB_DATABASE: parelpracht + ports: + - "3306:3306" + diff --git a/src/controllers/CompanyController.ts b/src/controllers/CompanyController.ts index 0e20a18..22a7666 100644 --- a/src/controllers/CompanyController.ts +++ b/src/controllers/CompanyController.ts @@ -143,7 +143,7 @@ export class CompanyController extends Controller { @Security('local', ['ADMIN']) @Response(401) public async deleteCompany( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new CompanyService().deleteCompany(id); } diff --git a/src/controllers/ContactController.ts b/src/controllers/ContactController.ts index 30aa63b..4e74deb 100644 --- a/src/controllers/ContactController.ts +++ b/src/controllers/ContactController.ts @@ -104,7 +104,7 @@ export class ContactController extends Controller { @Security('local', ['GENERAL', 'ADMIN']) @Response(401) public async deleteContact( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new ContactService().deleteContact(id); } diff --git a/src/controllers/ContractController.ts b/src/controllers/ContractController.ts index f009291..7b0ed9f 100644 --- a/src/controllers/ContractController.ts +++ b/src/controllers/ContractController.ts @@ -169,7 +169,7 @@ export class ContractController extends Controller { @Security('local', ['GENERAL', 'ADMIN']) @Response(401) public async deleteContract( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new ContractService().deleteContract(id); } diff --git a/src/controllers/InvoiceController.ts b/src/controllers/InvoiceController.ts index 2603a8a..fb55428 100644 --- a/src/controllers/InvoiceController.ts +++ b/src/controllers/InvoiceController.ts @@ -154,7 +154,7 @@ export class InvoiceController extends Controller { @Security('local', ['GENERAL', 'ADMIN']) @Response(401) public async deleteInvoice( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new InvoiceService().deleteInvoice(id); } diff --git a/src/controllers/ProductCategoryController.ts b/src/controllers/ProductCategoryController.ts index 99b872f..4763059 100644 --- a/src/controllers/ProductCategoryController.ts +++ b/src/controllers/ProductCategoryController.ts @@ -1,5 +1,5 @@ import { - Body, Controller, Delete, Get, Post, Put, Query, Request, Response, Route, Security, Tags, + Body, Controller, Delete, Get, Post, Put, Request, Response, Route, Security, Tags, } from 'tsoa'; import express from 'express'; import { body } from 'express-validator'; @@ -98,7 +98,7 @@ export class ProductCategoryController extends Controller { @Security('local', ['ADMIN']) @Response(401) public async deleteCategory( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new ProductCategoryService().deleteCategory(id); } diff --git a/src/controllers/ProductController.ts b/src/controllers/ProductController.ts index 74fd278..1ca72df 100644 --- a/src/controllers/ProductController.ts +++ b/src/controllers/ProductController.ts @@ -133,7 +133,7 @@ export class ProductController extends Controller { @Security('local', ['GENERAL', 'ADMIN']) @Response(401) public async deleteProduct( - id: number, @Request() req: express.Request, + id: number, ): Promise { return new ProductService().deleteProduct(id); } diff --git a/src/controllers/RootController.ts b/src/controllers/RootController.ts index 838292d..92d2d7c 100644 --- a/src/controllers/RootController.ts +++ b/src/controllers/RootController.ts @@ -1,8 +1,5 @@ import express from 'express'; -import { - Body, - Controller, Get, Post, Query, Request, Response, Route, Security, -} from 'tsoa'; +import { Body, Controller, Get, Post, Query, Request, Response, Route, Security } from 'tsoa'; import { body } from 'express-validator'; import { WrappedApiError } from '../helpers/error'; import { validate } from '../helpers/validation'; @@ -23,13 +20,18 @@ export interface GeneralPrivateInfo { export interface GeneralPublicInfo { loginMethod: LoginMethods; + setupDone: boolean, } @Route('') export class RootController extends Controller { @Post('setup') - public async postSetup(@Body() params: SetupParams): Promise { - return new ServerSettingsService().initialSetup(params); + public async postSetup(@Body() params: SetupParams, @Request() req: express.Request): Promise { + const user = await new ServerSettingsService().initialSetup(params); + if (user === undefined) { + return; + } + await new AuthService().login(user, req); } @Get('authStatus') @@ -105,7 +107,7 @@ export class RootController extends Controller { @Get('getPublicGeneralInfo') @Response(400) - public getPublicGeneralInfo(): GeneralPublicInfo { + public async getPublicGeneralInfo(): Promise { let loginMethod: LoginMethods; if (ldapEnabled()) { loginMethod = 'ldap'; @@ -113,8 +115,10 @@ export class RootController extends Controller { loginMethod = 'local'; } + const setupDone: boolean = (await new ServerSettingsService().getSetting('SETUP_DONE'))?.value === 'true'; return { loginMethod, + setupDone, }; } } diff --git a/src/controllers/VATController.ts b/src/controllers/VATController.ts index f6cf9c6..a1d77b2 100644 --- a/src/controllers/VATController.ts +++ b/src/controllers/VATController.ts @@ -1,5 +1,5 @@ import { - Body, Controller, Get, Post, Put, Query, Request, Response, Route, Security, Tags, + Body, Controller, Get, Post, Put, Request, Response, Route, Security, Tags, } from 'tsoa'; import express from 'express'; import { body } from 'express-validator'; diff --git a/src/database.ts b/src/database.ts index 94e94b5..8c65000 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,4 +1,5 @@ import { DataSource } from 'typeorm'; +import fs from 'fs'; import { Company } from './entity/Company'; import { Contact } from './entity/Contact'; import { Contract } from './entity/Contract'; @@ -32,6 +33,13 @@ const AppDataSource = new DataSource({ type: process.env.TYPEORM_CONNECTION as 'postgres' | 'mariadb' | 'mysql', username: process.env.TYPEORM_USERNAME, password: process.env.TYPEORM_PASSWORD, + ...(process.env.TYPEORM_SSL_ENABLED === 'true' && process.env.TYPEORM_SSL_CACERTS + ? { + ssl: { + ca: fs.readFileSync(process.env.TYPEORM_SSL_CACERTS), + }, + } + : {}), synchronize: process.env.TYPEORM_SYNCHRONIZE === 'true', logging: process.env.TYPEORM_LOGGING === 'true', entities: [Company, Contact, Contract, IdentityApiKey, IdentityLDAP, IdentityLocal, Invoice, Product, diff --git a/src/entity/activity/ContractActivity.ts b/src/entity/activity/ContractActivity.ts index d2ceffd..33fff65 100644 --- a/src/entity/activity/ContractActivity.ts +++ b/src/entity/activity/ContractActivity.ts @@ -15,7 +15,7 @@ export class ContractActivity extends BaseActivity { /** Contract related to this activity */ @ManyToOne(() => Contract, (contract) => contract.activities, { onDelete: 'CASCADE', - }) + }) @JoinColumn({ name: 'contractId' }) contract!: Contract; @@ -25,7 +25,7 @@ export class ContractActivity extends BaseActivity { enum: ContractStatus, nullable: true, update: false, - }) + }) subType!: ContractStatus | null; getRelatedEntity(): BaseEnt { diff --git a/src/entity/activity/InvoiceActivity.ts b/src/entity/activity/InvoiceActivity.ts index 05371f1..d21e6e1 100644 --- a/src/entity/activity/InvoiceActivity.ts +++ b/src/entity/activity/InvoiceActivity.ts @@ -17,7 +17,7 @@ export class InvoiceActivity extends BaseActivity { /** Invoice related to this activity */ @ManyToOne(() => Invoice, (invoice) => invoice.activities, { onDelete: 'CASCADE', - }) + }) @JoinColumn({ name: 'invoiceId' }) invoice!: Invoice; @@ -27,7 +27,7 @@ export class InvoiceActivity extends BaseActivity { enum: InvoiceStatus, nullable: true, update: false, - }) + }) subType!: InvoiceStatus | null; getRelatedEntity(): BaseEnt { diff --git a/src/entity/activity/ProductInstanceActivity.ts b/src/entity/activity/ProductInstanceActivity.ts index 8df65da..fefe258 100644 --- a/src/entity/activity/ProductInstanceActivity.ts +++ b/src/entity/activity/ProductInstanceActivity.ts @@ -17,7 +17,7 @@ export class ProductInstanceActivity extends BaseActivity { /** ProductInstance related to this activity */ @ManyToOne(() => ProductInstance, (productInstance) => productInstance.activities, { onDelete: 'CASCADE', - }) + }) @JoinColumn({ name: 'productInstanceId' }) productInstance!: ProductInstance; @@ -27,7 +27,7 @@ export class ProductInstanceActivity extends BaseActivity { enum: ProductInstanceStatus, nullable: true, update: false, - }) + }) subType!: ProductInstanceStatus | null; getRelatedEntity(): BaseEnt { diff --git a/src/helpers/filters.ts b/src/helpers/filters.ts index 6fa092a..29b01d6 100644 --- a/src/helpers/filters.ts +++ b/src/helpers/filters.ts @@ -1,4 +1,4 @@ -import { FindOptionsWhere, ILike, In } from 'typeorm'; +import { Brackets, FindOptionsWhere, ILike, In, SelectQueryBuilder } from 'typeorm'; import { BaseEnt } from '../entity/BaseEnt'; import { ListOrFilter, ListParams } from '../controllers/ListParams'; @@ -76,3 +76,62 @@ export function addQueryWhereClause(params: ListParams, searc return conditions.length > 0 ? conditions : undefined; } + +export function addQueryBuilderFilters(queryBuilder: SelectQueryBuilder, filters: ListOrFilter[]) { + filters.forEach(({ column, values }: ListOrFilter, index: number) => { + // only allows for one level deep relations + if (column.includes('.')) { + const alias = column.split('.')[0]; + + // only join if alias is not yet joined + const aliasExists = queryBuilder.expressionMap.aliases.some((a) => a.name === alias); + if (!aliasExists) { + if (alias === 'activities') { + // subquery to join only the latest related activity + queryBuilder.innerJoin( + `${queryBuilder.alias}.${alias}`, + alias, + `${alias}.createdAt = ( + SELECT MAX(subQuery.updatedAt) + FROM ${queryBuilder.alias}_activity subQuery + WHERE subQuery.${queryBuilder.alias}Id = ${queryBuilder.alias}.id AND subQuery.subType IS NOT NULL + )`); + } else { + queryBuilder.innerJoin(`${queryBuilder.alias}.${alias}`, alias); + } + } + } + const paramName = `param_${index}`; + // avoid ambiguity in the where clause + if (!column.includes('.')) { + column = `${queryBuilder.alias}.${column}`; + } + queryBuilder.andWhere(`${column} IN (:...${paramName})`, { [paramName]: values }); + }); +} + +export function addQueryBuilderSearch(queryBuilder: SelectQueryBuilder, searchString: string, searchFields: string[]) { + searchFields.forEach((field) => { + // only allows for one level deep relations + if (field.includes('.')) { + const alias = field.split('.')[0]; + + // only join if alias is not yet joined + const aliasExists = queryBuilder.expressionMap.aliases.some((a) => a.name === alias); + if (!aliasExists) { + queryBuilder.innerJoin(`${queryBuilder.alias}.${alias}`, alias); + } + } + }); + queryBuilder.andWhere( + new Brackets((qb) => { + searchFields.forEach(field => { + // avoid ambiguity in the where clause + if (!field.includes('.')) { + field = `${queryBuilder.alias}.${field}`; + } + qb.orWhere(`LOWER(${field}) LIKE LOWER(:value)`, { value: `%${searchString}%` }); + }); + }), + ); +} diff --git a/src/pdfgenerator/PdfGenerator.ts b/src/pdfgenerator/PdfGenerator.ts index 601e682..28d5328 100644 --- a/src/pdfgenerator/PdfGenerator.ts +++ b/src/pdfgenerator/PdfGenerator.ts @@ -169,7 +169,7 @@ export default class PdfGenerator { template = this.replaceAllSafe(template, '{{senderfunction}}', sender.function); template = replaceAll(template, '{{dateday}}', date.getDate().toString()); - template = replaceAll(template, '{{datemonth}}', (date.getMonth()+1).toString()); + template = replaceAll(template, '{{datemonth}}', (date.getMonth() + 1).toString()); template = replaceAll(template, '{{dateyear}}', date.getFullYear().toString()); if (useInvoiceAddress) { @@ -196,7 +196,7 @@ export default class PdfGenerator { let dueDate = new Date(date); dueDate.setDate(date.getDate() + 30); template = replaceAll(template, '{{dueday}}', dueDate.getDate().toString()); - template = replaceAll(template, '{{duemonth}}', (dueDate.getMonth()+1).toString()); + template = replaceAll(template, '{{duemonth}}', (dueDate.getMonth() + 1).toString()); template = replaceAll(template, '{{dueyear}}', dueDate.getFullYear().toString()); return template; @@ -416,7 +416,7 @@ export default class PdfGenerator { let dueDate = new Date(invoice.startDate); dueDate.setDate(invoice.startDate.getDate() + 30); file = replaceAll(file, '{{dueday}}', dueDate.getDate().toString()); - file = replaceAll(file, '{{duemonth}}', (dueDate.getMonth()+1).toString()); + file = replaceAll(file, '{{duemonth}}', (dueDate.getMonth() + 1).toString()); file = replaceAll(file, '{{dueyear}}', dueDate.getFullYear().toString()); file = replaceAll(file, '{{debtornumber}}', `C${settings.recipient.id}`); @@ -478,7 +478,7 @@ export default class PdfGenerator { let dueDate = new Date(params.date); dueDate.setDate(params.date.getDate() + 30); file = replaceAll(file, '{{dueday}}', dueDate.getDate().toString()); - file = replaceAll(file, '{{duemonth}}', (dueDate.getMonth()+1).toString()); + file = replaceAll(file, '{{duemonth}}', (dueDate.getMonth() + 1).toString()); file = replaceAll(file, '{{dueyear}}', dueDate.getFullYear().toString()); file = replaceAll(file, '{{debtornumber}}', params.recipient.number); diff --git a/src/routes.ts b/src/routes.ts index 352d82b..8125983 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1176,6 +1176,8 @@ const models: TsoaRoute.Models = { "lastName": {"dataType":"string","required":true}, "function": {"dataType":"string","required":true}, "gender": {"ref":"Gender","required":true}, + "password": {"dataType":"string","required":true}, + "rememberMe": {"dataType":"boolean","required":true}, "replyToEmail": {"dataType":"string"}, "receiveEmails": {"dataType":"boolean"}, "sendEmailsToReplyToEmail": {"dataType":"boolean"}, @@ -1257,6 +1259,7 @@ const models: TsoaRoute.Models = { "dataType": "refObject", "properties": { "loginMethod": {"ref":"LoginMethods","required":true}, + "setupDone": {"dataType":"boolean","required":true}, }, "additionalProperties": false, }, @@ -1287,7 +1290,7 @@ const models: TsoaRoute.Models = { // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "Partial_UserParams_": { "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"email":{"dataType":"string"},"firstName":{"dataType":"string"},"lastNamePreposition":{"dataType":"string"},"lastName":{"dataType":"string"},"function":{"dataType":"string"},"gender":{"ref":"Gender"},"replyToEmail":{"dataType":"string"},"receiveEmails":{"dataType":"boolean"},"sendEmailsToReplyToEmail":{"dataType":"boolean"},"comment":{"dataType":"string"},"ldapOverrideEmail":{"dataType":"boolean"},"roles":{"dataType":"array","array":{"dataType":"refEnum","ref":"Roles"}}},"validators":{}}, + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"email":{"dataType":"string"},"firstName":{"dataType":"string"},"lastNamePreposition":{"dataType":"string"},"lastName":{"dataType":"string"},"function":{"dataType":"string"},"gender":{"ref":"Gender"},"password":{"dataType":"string"},"rememberMe":{"dataType":"boolean"},"replyToEmail":{"dataType":"string"},"receiveEmails":{"dataType":"boolean"},"sendEmailsToReplyToEmail":{"dataType":"boolean"},"comment":{"dataType":"string"},"ldapOverrideEmail":{"dataType":"boolean"},"roles":{"dataType":"array","array":{"dataType":"refEnum","ref":"Roles"}}},"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "TransferUserParams": { @@ -1601,7 +1604,6 @@ export function RegisterRoutes(app: Router) { function CompanyController_deleteCompany(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -2085,7 +2087,6 @@ export function RegisterRoutes(app: Router) { function ContactController_deleteContact(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -2270,7 +2271,6 @@ export function RegisterRoutes(app: Router) { function ContractController_deleteContract(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -2930,7 +2930,6 @@ export function RegisterRoutes(app: Router) { function InvoiceController_deleteInvoice(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -3394,7 +3393,6 @@ export function RegisterRoutes(app: Router) { function ProductCategoryController_deleteCategory(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -3579,7 +3577,6 @@ export function RegisterRoutes(app: Router) { function ProductController_deleteProduct(request: any, response: any, next: any) { const args = { id: {"in":"path","name":"id","required":true,"dataType":"double"}, - req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -4065,6 +4062,7 @@ export function RegisterRoutes(app: Router) { function RootController_postSetup(request: any, response: any, next: any) { const args = { params: {"in":"body","name":"params","required":true,"ref":"SetupParams"}, + req: {"in":"request","name":"req","required":true,"dataType":"object"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index b68fe54..5c1b92d 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -85,6 +85,17 @@ export default class AuthService { }); } + login(user: User, req: express.Request) { + return new Promise((resolve, reject) => { + req.logIn(user, (err) => { + if (err) { + return reject(err); + } + return resolve(); + }); + }); + } + async forgotPassword(userEmail: string): Promise { let email = validator.normalizeEmail(userEmail); if (email === false) { diff --git a/src/services/ContractService.ts b/src/services/ContractService.ts index 376d6ab..a99bb19 100644 --- a/src/services/ContractService.ts +++ b/src/services/ContractService.ts @@ -1,12 +1,11 @@ import { - FindManyOptions, Repository, + Repository, } from 'typeorm'; import { ListParams } from '../controllers/ListParams'; import { Contract } from '../entity/Contract'; import { User } from '../entity/User'; import { ApiError, HTTPStatus } from '../helpers/error'; import { ContractActivity } from '../entity/activity/ContractActivity'; -// eslint-disable-next-line import/no-cycle import ActivityService, { FullActivityParams } from './ActivityService'; import ContactService from './ContactService'; import CompanyService from './CompanyService'; @@ -15,7 +14,7 @@ import { CompanyStatus } from '../entity/enums/CompanyStatus'; import { ActivityType } from '../entity/enums/ActivityType'; import RawQueries, { RecentContract } from '../helpers/rawQueries'; import { ContractStatus } from '../entity/enums/ContractStatus'; -import { addQueryWhereClause } from '../helpers/filters'; +import { addQueryBuilderFilters, addQueryBuilderSearch } from '../helpers/filters'; import { Roles } from '../entity/enums/Roles'; import { ContractSummary } from '../entity/Summaries'; import { @@ -59,22 +58,25 @@ export default class ContractService { } async getAllContracts(params: ListParams): Promise { - const findOptions: FindManyOptions = { - order: { - [params.sorting?.column ?? 'id']: - params.sorting?.direction ?? 'ASC', - }, - }; + const queryBuilder = this.repo.createQueryBuilder('contract'); + + queryBuilder + .orderBy(`${queryBuilder.alias}.${params.sorting?.column ?? 'id'}`, params.sorting?.direction ?? 'ASC') + // initial where to allow chaining andWhere() function calls + .where('1 = 1') + ; - findOptions.where = addQueryWhereClause(params, ['title', 'company.name', 'contact.firstName', 'contact.lastNamePreposition', 'contact.lastName']); + if (params.search) { + addQueryBuilderSearch(queryBuilder, params.search, ['title', 'company.name', 'contact.firstName', 'contact.lastNamePreposition', 'contact.lastName']); + } + + if (params.filters && params.filters.length > 0) { + addQueryBuilderFilters(queryBuilder, params.filters); + } return { - list: await this.repo.find({ - ...findOptions, - skip: params.skip, - take: params.take, - }), - count: await this.repo.count(findOptions), + list: await queryBuilder.skip(params.skip).take(params.take).getMany(), + count: await queryBuilder.getCount(), }; } diff --git a/src/services/InvoiceService.ts b/src/services/InvoiceService.ts index 3bd3987..1186886 100644 --- a/src/services/InvoiceService.ts +++ b/src/services/InvoiceService.ts @@ -1,12 +1,12 @@ import { - FindManyOptions, Repository, + Repository, } from 'typeorm'; import { ListParams } from '../controllers/ListParams'; import { Invoice } from '../entity/Invoice'; import { ProductInstance } from '../entity/ProductInstance'; import { User } from '../entity/User'; import { ApiError, HTTPStatus } from '../helpers/error'; -import { addQueryWhereClause } from '../helpers/filters'; +import { addQueryBuilderFilters, addQueryBuilderSearch } from '../helpers/filters'; import ProductInstanceService from './ProductInstanceService'; import ActivityService, { FullActivityParams } from './ActivityService'; import RawQueries, { ExpiredInvoice } from '../helpers/rawQueries'; @@ -61,23 +61,25 @@ export default class InvoiceService { } async getAllInvoices(params: ListParams): Promise { - const findOptions: FindManyOptions = { - order: { - [params.sorting?.column ?? 'id']: - params.sorting?.direction ?? 'ASC', - }, - }; + const queryBuilder = this.repo.createQueryBuilder('invoice'); + + queryBuilder + .orderBy(`${queryBuilder.alias}.${params.sorting?.column ?? 'id'}`, params.sorting?.direction ?? 'ASC') + // initial where() to allow chaining andWhere() function calls + .where('1 = 1') + ; - findOptions.where = addQueryWhereClause(params, ['title', 'company.name']); + if (params.search) { + addQueryBuilderSearch(queryBuilder, params.search, ['title', 'company.name']); + } + + if (params.filters && params.filters.length > 0) { + addQueryBuilderFilters(queryBuilder, params.filters); + } return { - list: await this.repo.find({ - ...findOptions, - skip: params.skip, - take: params.take, - }), - count: await this.repo.count(findOptions), - lastSeen: await this.getTreasurerLastSeen(), + list: await queryBuilder.skip(params.skip).take(params.take).getMany(), + count: await queryBuilder.getCount(), }; } diff --git a/src/services/ServerSettingsService.ts b/src/services/ServerSettingsService.ts index 2717e4d..a7ff1b6 100644 --- a/src/services/ServerSettingsService.ts +++ b/src/services/ServerSettingsService.ts @@ -7,6 +7,7 @@ import AuthService from './AuthService'; import UserService, { UserParams } from './UserService'; import { ldapEnabled } from '../auth'; import AppDataSource from '../database'; +import { User } from '../entity/User'; export interface SetupParams { admin: UserParams, @@ -29,7 +30,7 @@ export default class ServerSettingsService { async initialSetup( params: SetupParams, - ): Promise { + ): Promise { if ((await this.getSetting('SETUP_DONE'))?.value === 'true') { throw new ApiError(HTTPStatus.Forbidden, 'Server is already set up'); } @@ -39,9 +40,14 @@ export default class ServerSettingsService { const adminUser = await new UserService() .createAdminUser(admin); - new AuthService().createIdentityLocal(adminUser!, ldapEnabled()); + const authService = new AuthService(); + + const identity = await authService.createIdentityLocal(adminUser!, true); + await authService.resetPassword(params.admin.password, authService.getSetPasswordToken(adminUser!, identity)); await this.setSetting({ name: 'SETUP_DONE', value: 'true' }); + return adminUser!; } + return undefined; } } diff --git a/src/services/StatisticsService.ts b/src/services/StatisticsService.ts index b8acdcf..79f3977 100644 --- a/src/services/StatisticsService.ts +++ b/src/services/StatisticsService.ts @@ -40,13 +40,6 @@ function rangeToArray(start: number, end: number, step: number): number[] { return result; } -function appendZeroesToStart(array: number[], newLength: number) { - while (array.length < newLength) { - array.splice(0, 0, 0); - } - return array; -} - export default class StatisticsService { public async getFinancialYears(firstYear?: number): Promise { if (firstYear) return rangeToArray(firstYear, dateToFinancialYear(new Date()), 1); diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 5e7c478..c29071f 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -17,6 +17,7 @@ import FileHelper, { uploadUserAvatarDirLoc, uploadUserBackgroundDirLoc } from ' import { IdentityLDAP } from '../entity/IdentityLDAP'; import { ldapEnabled } from '../auth'; import AppDataSource from '../database'; +import validator from 'validator'; export interface UserParams { email: string; @@ -25,6 +26,8 @@ export interface UserParams { lastName: string; function: string; gender: Gender; + password: string; + rememberMe: boolean; replyToEmail?: string; receiveEmails?: boolean; sendEmailsToReplyToEmail?: boolean; @@ -164,6 +167,10 @@ export default class UserService { async createAdminUser(params: UserParams): Promise { if (ldapEnabled()) return Promise.resolve(undefined); + if (!this.validateUserParams(params)) { + throw new ApiError(HTTPStatus.BadRequest, 'The supplied parameters are not valid'); + } + const adminUser = await this.repo.save({ email: params.email, gender: params.gender, @@ -210,6 +217,15 @@ export default class UserService { return user!; } + private validateUserParams(params: UserParams): boolean { + return validator.isStrongPassword(params.password) && + validator.isEmail(params.email) && + !validator.isEmpty(params.firstName) && + !validator.isEmpty(params.lastName) && + !validator.isEmpty(params.email) && + !validator.isEmpty(params.gender); + } + async setupRoles() { await this.roleRepo.save( this.roleRepo.create([