diff --git a/README.md b/README.md index 1449579..2b9bd6a 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,18 @@ A work in progress - Experiment in which we attempt to manage invoices, expenses, budgets and analysis in a transparent manner. ## .env -```MONGODB_URI``` Path to reach your database (e.g.```mongodb+srv://:@cluster0-e7fdv.mongodb.net/test?retryWrites=true```) +```MONGODB_URI``` Path to reach your database (e.g.```mongodb+srv://:@cluster0-e7fdv.mongodb.net/test?retryWrites=true```) -```DB_NAME``` The database name (e.g. ```open-wallet``` or ```test```) +```DB_NAME``` The database name (e.g. ```open-wallet``` or ```test```) -```APP_SECRET``` A secret used by JWT to sign tokens, e.g. ```A secret is a secret``` (this is not a good secret) +```APP_SECRET``` A secret used by JWT to sign tokens, e.g. ```A secret is a secret``` (this is not a good secret) + +```CLOUDINARY_URL``` e.g. ```cloudinary://:@``` + +```STRING_MAX_CHAR``` maximum string length authorized for user inputs (```default 500```) + +```HASH_ROUNDS``` bcrypt hash rounds (```default 10```) + +```INVOICE_REF_SIZE``` number of characters of an invoice reference (```default 4```) [See example](./backend/.env.example) diff --git a/backend/.env.example b/backend/.env.example index 9dffe09..717002f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,3 +1,7 @@ MONGODB_URI=mongodb+srv://:@cluster0-e7fdv.mongodb.net/test?retryWrites=true DB_NAME=test -APP_SECRET=this_is_a_secret \ No newline at end of file +APP_SECRET=this_is_a_secret +CLOUDINARY_URL=cloudinary://:@ +STRING_MAX_CHAR=1000 +HASH_ROUNDS=13 +INVOICE_REF_SIZE=4 \ No newline at end of file diff --git a/backend/src/auth.js b/backend/src/auth.js index ca067b5..275e9d3 100644 --- a/backend/src/auth.js +++ b/backend/src/auth.js @@ -29,7 +29,7 @@ const loggedUser = async ({ token }, { User }) => { /** * Returns whether a user is logged in or not. * @param { object } user - logged in user - * @returns {boolean} True of false + * @returns {boolean} True or false */ const loggedIn = ({ user }) => !!user; diff --git a/backend/src/constants.js b/backend/src/constants.js index 3a61baa..aca0f07 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -1,3 +1,8 @@ +/** + * Gather constants that are neither env variables nor messages. + * TR = transaction + */ + exports.TR_FLOW = { IN: 'IN', OUT: 'OUT' diff --git a/backend/src/db.js b/backend/src/db.js index dec81a7..7a1390b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,5 +1,8 @@ const mongoose = require('mongoose'); +/** + * Open connection to the database and creates collections. + */ module.exports.connect = async () => { return mongoose .connect(process.env.MONGODB_URI, { @@ -21,6 +24,9 @@ module.exports.connect = async () => { }); }; +/** + * Close the database connection. Used in tests. + */ module.exports.disconnect = async () => { return mongoose .disconnect() @@ -30,4 +36,7 @@ module.exports.disconnect = async () => { }); }; +/** + * Expose mongoose startSession function. + */ module.exports.startSession = async () => mongoose.startSession(); diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index edf5505..fac6925 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -1,3 +1,12 @@ +/** + * Saves (updates) or retrieves a company. + * If id is set the company will only be retrieved + * If name is set (without id), the company will be updated. + * @param {Company} company company's data + * @param {Model} Company Mongoose model + * @param {boolean} upsert true will insert if not found when only a name is provided. + * @returns {Promise} Promise that represents the company + */ const saveOrRetrieveCompany = async (company, Company, upsert) => { if (!company) return null; const { name, id } = company; @@ -11,17 +20,32 @@ const saveOrRetrieveCompany = async (company, Company, upsert) => { return null; }; +/** + * Returns an invoice reference. + * @param {string} id Counter document id + * @param {Model} Counter Mongoose model + * @returns {string} the reference + */ const getInvoiceRef = async (id, Counter) => { const counter = await Counter.findByIdAndUpdate( id, { $inc: { sequence: 1 } }, { new: true, upsert: true } ); - const INVOICE_REF_SIZE = process.env.INVOICE_REF_SIZE || 4; + const INVOICE_REF_SIZE = parseInt(process.env.INVOICE_REF_SIZE, 10) || 4; const z = '0'; return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`; }; +/** + * Stores file by passing it to the cloud storage + * @param {Stream} file a file stream + * @param {string} tags tags to give to the file + * @param {string} folder folder in which to store the file + * @param {object} cloudinary cloudinary functions + * @param {boolean} generated flag that tells whether the file has been generated by the backend or not + * @returns {Promise} Promise that represents the file + */ const store = (file, tags, folder, cloudinary, generated) => new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => { @@ -139,7 +163,6 @@ module.exports = { const { formatData, rules, messages } = uploadInvoiceValidation; await validate(formatData({ ...invoice }), rules, messages); - // look for a company (by id or name), if name is provided: update it or save it if doesn't exist const company = await saveOrRetrieveCompany(invoice.company, Company, true); invoice.company = company; if (invoice.category && (invoice.category.name || invoice.category.id)) { @@ -174,7 +197,6 @@ module.exports = { ) => { const { formatData, rules, messages } = generateInvoiceValidation; - // look for a company (by id or name), if name is provided: update it or save it if doesn't exist const company = await saveOrRetrieveCompany(invoice.company, Company, true); invoice.company = company; @@ -184,6 +206,8 @@ module.exports = { invoice.flow = TR_FLOW.OUT; invoice.date = Date.now(); + // TODO add category to the invoice + const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter); invoice.ref = noInvoice; diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index 144d91b..8a0f988 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -6,21 +6,31 @@ module.exports = gql` # types type Query { - users: [User]! @auth + "Returns all the users - inaccessible for unauthenticated users" + users: [User]! @auth #TODO restrict access + "Returns the authenticated user profile" me: User @auth + "Returns the authenticated user expenses" myExpenses: [Transaction]! @auth - transactions: [Transaction]! @auth + "Returns all transactions - inaccessible for unauthenticated users" + transactions: [Transaction]! @auth #TODO restrict access + "Returns all companies - inaccessible for unauthenticated users" companies: [Company] @auth } type Mutation { + "Registers a user" register(user: UserInput!): User! @guest logout: Boolean! login(email: String!, password: String!): User! @guest + "Handles expense claims - inaccessible for unauthenticated users" expenseClaim(expense: Expense!): Transaction! @auth + "Handles profile updates - inaccessible for unauthenticated users" updateProfile(user: UserUpdateInput!): User! @auth - uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth - generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth + "Handles invoice uploads - inaccessible for unauthenticated users" + uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth #TODO restrict access + "Handles invoice generations - inaccessible for unauthenticated users" + generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth #TODO restrict access } type Success { status: Boolean! @@ -32,7 +42,7 @@ module.exports = gql` zipCode: Int! } - type BankDetails { + type BankDetails { # need attention iban: String! bic: String } @@ -58,6 +68,7 @@ module.exports = gql` expDate: String description: String file: String + "VAT rate" VAT: Int type: TransactionType! ref: String @@ -72,6 +83,7 @@ module.exports = gql` name: String! email: String phone: String + "VAT number" VAT: String bankDetails: BankDetails address: Address @@ -111,6 +123,7 @@ module.exports = gql` date: String description: String! receipt: Upload! + "VAT rate" VAT: Int } @@ -121,6 +134,7 @@ module.exports = gql` company: CompanyInput expDate: String invoice: Upload! + "VAT rate" VAT: Int } @@ -137,6 +151,8 @@ module.exports = gql` input GenerateInvoiceInput { company: CompanyInput! details: [GenerateInvoiceDetailsInput!]! + # add category + "VAT rate" VAT: Int! } @@ -146,13 +162,20 @@ module.exports = gql` } #enums + + "Transaction flow" enum Flow { + "Incoming transaction" IN + "Outgoing transaction" OUT } + "Transaction state" enum State { + "An unreviewed transaction" UNCLEARED + "An accepted transaction" CLEARED PAID REJECTED diff --git a/backend/src/index.js b/backend/src/index.js index bede14d..7b821bd 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -72,7 +72,6 @@ if (process.env.NODE_ENV !== 'test') { module.exports = { models, typeDefs, - // TODO exports resolvers in their on folder via index resolvers: { Query, Mutation diff --git a/backend/src/invoiceFactory.js b/backend/src/invoiceFactory.js index a736234..7520839 100644 --- a/backend/src/invoiceFactory.js +++ b/backend/src/invoiceFactory.js @@ -2,6 +2,7 @@ const pdf = require('html-pdf'); const options = { format: 'Letter' }; +// TODO retrieve from the database const organization = { name: 'Open Knowledge Belgium vzw', address: { @@ -23,7 +24,16 @@ const renderContactDetails = contact => { }`; }; -module.exports = /* sender */ (details, metadata, receiver) => { +/** + * Generate an invoice. + * @example + * generateInvoice({[description: "", amount: 0]}, {VAT: 21, date: 13/05/2019, noInvoice: "2019/00001"}, company: Company) + * @param {object} details - invoice details (see example) + * @param {object} metadata - invoice metadata (see example) + * @param {Company} company buying company + * @returns {Promise} Promise that represents the file stream or an error + */ +module.exports = (details, metadata, receiver) => { const total = details.reduce((acc, current) => acc + current.amount, 0); const amountVAT = (total / 100) * metadata.VAT; const totalInclVAT = total + amountVAT; diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index ea2fe16..8c15315 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -1,4 +1,8 @@ /* eslint-disable no-useless-escape */ +/** + * We are using the api of the library indicative to achieve user input validation. + * Please refer to the doc: http://indicative.adonisjs.com/ + */ const { validateAll, configure } = require('indicative'); const { UserInputError } = require('apollo-server-express'); const { diff --git a/backend/src/messages.js b/backend/src/messages.js index 66358f0..e014876 100644 --- a/backend/src/messages.js +++ b/backend/src/messages.js @@ -1,4 +1,6 @@ -// export constant messages +/** + * Gather constant (format of) messages that are sent to the client. + */ module.exports = { MUST_LOGIN: 'You must be logged in.', MUST_LOGOUT: 'You must be logged out.', diff --git a/backend/src/models/Counter.js b/backend/src/models/Counter.js index 2a335d3..05589e2 100644 --- a/backend/src/models/Counter.js +++ b/backend/src/models/Counter.js @@ -1,3 +1,6 @@ +/** + * Model that creates unique sequential ids. + */ const mongoose = require('mongoose'); const { Schema } = mongoose; diff --git a/backend/src/models/User.js b/backend/src/models/User.js index dae711f..effe3b9 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -4,6 +4,8 @@ const bcrypt = require('bcrypt'); const address = require('./address'); const bankDetails = require('./bankDetails'); +const HASH_ROUNDS = parseInt(process.env.HASH_ROUNDS, 10) || 10; + const { Schema } = mongoose; const userSchema = new Schema({ @@ -30,6 +32,11 @@ const userSchema = new Schema({ ] }); +/** + * Mongoose middleware. + * Hash the password and check for email existance. + * @throws {Error} if email found + */ userSchema.pre('save', async function() { const { email } = this; // accessing the constructor to make request against the db @@ -38,20 +45,25 @@ userSchema.pre('save', async function() { throw new Error('Email already exists!'); } if (this.isModified('password')) { - // TODO Retrieve hash factor from env - this.password = await bcrypt.hash(this.password, 10); + this.password = await bcrypt.hash(this.password, HASH_ROUNDS); } }); +/** + * Mongoose middleware. + * Hash the password if provided. + */ userSchema.pre('findOneAndUpdate', async function() { const input = this.getUpdate(); const { password } = input; if (password) { - // TODO Retrieve hash factor from env - this.getUpdate().password = await bcrypt.hash(password, 10); + this.getUpdate().password = await bcrypt.hash(password, HASH_ROUNDS); } }); +/** + * Expose bcrypt compare function. + */ userSchema.methods.rightPassword = async function(password) { return bcrypt.compare(password, this.password); }; diff --git a/backend/src/models/address.js b/backend/src/models/address.js index c1290cf..e1ee616 100644 --- a/backend/src/models/address.js +++ b/backend/src/models/address.js @@ -1,3 +1,4 @@ +// This schema is meant to be a subdocument common to several documents const mongoose = require('mongoose'); const { Schema } = mongoose; diff --git a/backend/src/models/bankDetails.js b/backend/src/models/bankDetails.js index 4c3c99c..5898f5a 100644 --- a/backend/src/models/bankDetails.js +++ b/backend/src/models/bankDetails.js @@ -1,3 +1,4 @@ +// This schema is meant to be a subdocument common to several documents const mongoose = require('mongoose'); const { Schema } = mongoose; diff --git a/backend/src/tests/utils.js b/backend/src/tests/utils.js index a21d16c..3bf4818 100644 --- a/backend/src/tests/utils.js +++ b/backend/src/tests/utils.js @@ -1,4 +1,9 @@ /* eslint-disable import/no-extraneous-dependencies */ + +/** + * inspired by: https://github.com/apollographql/fullstack-tutorial/blob/master/final/server/src/__tests__/__utils.js + */ + const { HttpLink } = require('apollo-link-http'); const fetch = require('node-fetch'); const { execute, toPromise } = require('apollo-link'); @@ -58,32 +63,11 @@ const startTestServer = async server => { }; }; -// /** -// * Upload server -// */ -// const startTestUploadServer = async server => { -// server.applyMiddleware({ app }); -// const httpServer = await app.listen(0); - -// const link = new HttpLink({ -// uri: `http://localhost:${httpServer.address().port}${server.graphqlPath}`, -// fetch -// }); - -// const executeOperation = ({ query, variables = {} }) => execute(link, { query, variables }); - -// return { -// link, -// stop: async () => { -// httpServer.close(); -// }, -// graphql: executeOperation -// }; -// }; - module.exports.startTestServer = startTestServer; -// clean database +/** + * Cleans the database. + */ const clean = async () => { const { User, Transaction, Company, Counter } = models; await User.deleteMany({}); @@ -92,8 +76,9 @@ const clean = async () => { await Counter.deleteMany({}); }; -// populate database - +/** + * Populates the database with mock data. + */ const populate = async () => { const { User } = models; diff --git a/frontend/components/InvoiceCreationForm.jsx b/frontend/components/InvoiceCreationForm.jsx index a25ef58..7523715 100644 --- a/frontend/components/InvoiceCreationForm.jsx +++ b/frontend/components/InvoiceCreationForm.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react/forbid-prop-types */ /* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-for */ import React, { useState } from 'react'; @@ -26,7 +27,11 @@ const CompanyDropdownStyle = styled.div` const Details = ({ details: { details, setDetail }, errors }) => { const addDetail = () => { - setDetail([...details, { id: details[details.length - 1].id + 1 }]); + let id = 0; + if (details.length) { + id = details[details.length - 1].id + 1; + } + setDetail([...details, { id }]); }; const removeDetail = idx => { @@ -95,7 +100,9 @@ Details.propTypes = { }) ), setDetail: PropTypes.func - }).isRequired + }).isRequired, + // eslint-disable-next-line react/require-default-props + errors: PropTypes.object.isRequired }; const Company = ({ @@ -256,7 +263,9 @@ Company.propTypes = { selectedCompany: PropTypes.shape({ selectedCompany: companyType, setSelectedCompany: PropTypes.func - }).isRequired + }).isRequired, + // eslint-disable-next-line react/forbid-prop-types + errors: PropTypes.object.isRequired }; const renderUI = ( @@ -432,7 +441,7 @@ FormManager.propTypes = { const Main = () => { return ( - {({ data }) => } + {({ data, loading }) => !loading && } ); }; diff --git a/frontend/components/commons/ErrorMessage.jsx b/frontend/components/commons/ErrorMessage.jsx index 7b55e37..d0187fe 100644 --- a/frontend/components/commons/ErrorMessage.jsx +++ b/frontend/components/commons/ErrorMessage.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-array-index-key */ import React from 'react'; import { Message, Icon } from 'semantic-ui-react'; import PropTypes from 'prop-types'; @@ -8,8 +9,13 @@ const ErrorMessage = ({ error }) => { {error && ( -

{error.message}

-

Please make sure to provide all required fieds.

+ {error.networkError && ( +

+ Network error! Please check your internet connection and make sure to provide all + required values. +

+ )} + {error.graphQLErrors && error.graphQLErrors.map((err, i) =>

{err.message}

)}
)} diff --git a/frontend/components/commons/InputField.jsx b/frontend/components/commons/InputField.jsx index 7e73045..13aebdc 100644 --- a/frontend/components/commons/InputField.jsx +++ b/frontend/components/commons/InputField.jsx @@ -22,6 +22,7 @@ const InputField = ({ apolloClient .query({ diff --git a/frontend/lib/formatErrors.js b/frontend/lib/formatErrors.js index 45a004e..86eb64b 100644 --- a/frontend/lib/formatErrors.js +++ b/frontend/lib/formatErrors.js @@ -1,3 +1,8 @@ +/** + * Transforms an array of errors into an object of errors. + * @param {array} errs An array of indicative validation errors + * @returns {object} An object + */ export default errs => { const obj = {}; if (!Array.isArray(errs)) return obj; diff --git a/frontend/lib/redirect.js b/frontend/lib/redirect.js index 0412782..68a4478 100644 --- a/frontend/lib/redirect.js +++ b/frontend/lib/redirect.js @@ -1,3 +1,6 @@ +/** + * https://github.com/zeit/next.js/blob/canary/examples/with-apollo-auth/lib/redirect.js + */ import Router from 'next/router'; export default (context, target) => { diff --git a/frontend/lib/validation.js b/frontend/lib/validation.js index b4cb4ee..bd1c83d 100644 --- a/frontend/lib/validation.js +++ b/frontend/lib/validation.js @@ -1,3 +1,8 @@ +/** + * This file gathers some reused validation rules using the indicative validation api. + * see doc: https://indicative.adonisjs.com/ + */ + const MIN_PASSWORD_LENGTH = 8; const REQUIRED = fieldName => `${fieldName} is required.`; diff --git a/frontend/lib/withApollo.js b/frontend/lib/withApollo.js index 23347f2..2b76280 100644 --- a/frontend/lib/withApollo.js +++ b/frontend/lib/withApollo.js @@ -1,3 +1,8 @@ +/** + * This file will configure an apollo client. + * inspiration: https://github.com/lfades/next-with-apollo/issues/13#issuecomment-390289449 + apollo client docs + */ + import ApolloClient from 'apollo-client'; import { ApolloLink } from 'apollo-link'; import { createUploadLink } from 'apollo-upload-client'; diff --git a/frontend/next.config.js b/frontend/next.config.js index d4ad230..b0c314d 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,5 +4,5 @@ const withImages = require('next-images'); const withFonts = require('next-fonts'); // Don't enable CSS modules -// To prevent blueprint classes from being hashed +// To prevent css classes from being hashed module.exports = withPlugins([[withCSS], [withFonts], [withImages]]);