Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
84b6ebd
Lowercase import names & uppercase file name
Apr 30, 2019
16c40e9
Create Company model
Apr 30, 2019
7ce84ab
Create Category model
Apr 30, 2019
af5dc88
Create graphql types Category & Company
Apr 30, 2019
37b3222
Add category & company to Transaction
Apr 30, 2019
f33bc2a
Add InvoiceUpload & CompanyInput
Apr 30, 2019
b10b1cd
Add invoice upload validation & make the validation components more r…
May 2, 2019
fcd5d6b
Implement upload invoice resolver
May 2, 2019
0694119
Test register input validation
May 2, 2019
0d7d107
Better error handling & password test
May 2, 2019
5f882cf
Add env variable to set maximum characters allowed for strings
May 2, 2019
76f509c
Inject validation module
May 2, 2019
78ba2fe
Test max length for strings
May 2, 2019
55f6aee
Test updateProfileValidtion & expenseValidation
May 2, 2019
5bc435e
Test validation of upload invoice inputs
May 2, 2019
720102e
Add more tests for register endpoint
May 3, 2019
e7ea283
Test update profile
May 3, 2019
baf3a88
Test invoice upload
May 3, 2019
6a13af8
Add constants & improve test a bit
May 6, 2019
66a9eb6
Generate an invoice as pdf
May 6, 2019
48fa906
Add ref field
May 7, 2019
773f9b8
Create Counter collection
May 7, 2019
29db764
Create function to retrieve incremented invoice ref
May 7, 2019
c7f5eaf
Fix typo
May 7, 2019
1600ab1
Create types related to invoice generation
May 7, 2019
b67a6d1
Implement generateInvoice resolver & change store function behavior
May 7, 2019
e583b3b
Modify pdf template
May 7, 2019
49eeb34
Add generateInvoiceInputValidation & refine some resolvers
May 7, 2019
1f55baa
Clean Company & Counter test data
May 7, 2019
ed81504
Test generate invoice
May 7, 2019
1511e2c
Test generate invoice validation
May 7, 2019
7650f43
Change filename invoiceGen -> invoiceFactory & import name invoiceGen…
May 7, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports.connect = async () => {
await mongoose.connection.createCollection('users');
await mongoose.connection.createCollection('transactions');
await mongoose.connection.createCollection('categories');
await mongoose.connection.createCollection('companies');
console.log('Collections created successfully!');
})
.catch(err => {
Expand Down
50 changes: 48 additions & 2 deletions backend/src/graphql/resolvers/mutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@ const {
validate,
registerValidation,
updateProfileValidation,
expenseValidation
expenseValidation,
uploadInvoiceValidation
} = require('../../lib/validation');

const saveOrRetrieveCompany = async (company, Company) => {
if (!company) return null;
const { name, id } = company;
if (!name && !id) return null;
if (company.id) {
return Company.findById(id);
}
if (company) {
const cmpny = await Company.findOne({ name });
if (cmpny) return cmpny;
return new Company(company).save();
}
return null;
};

const store = (file, tags, folder, cloudinary) =>
new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => {
Expand Down Expand Up @@ -87,12 +103,42 @@ module.exports = {
}
}
return User.findOneAndUpdate({ _id: user.id }, args.user, { new: true });
},
uploadInvoice: async (
root,
{ invoice },
{ models: { Transaction, Company, Category }, cloudinary }
) => {
const { formatData, rules, messages } = uploadInvoiceValidation;
await validate(formatData({ ...invoice }), rules, messages);
const company = await saveOrRetrieveCompany(invoice.company, Company);
invoice.company = company;
if (invoice.category && (invoice.category.name || invoice.category.id)) {
const category = Category.findOne({
$or: [{ _id: company.category.id }, { name: company.category.id }]
});
if (!category) {
throw new Error('Category not found.');
}
invoice.category = category;
}
invoice.flow = 'IN';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe these should be constants declared in a constants file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't think about it, yeah it'd better that way.

invoice.type = 'INVOICE';
invoice.date = invoice.date || Date.now();
invoice.invoice = await invoice.invoice;

const file = await store(invoice.invoice, 'invoice', '/invoices/pending', cloudinary);
invoice.file = file.secure_url;

return new Transaction(invoice).save();

// console.log(invoice);
}
};

// TODO REMOVE EXAMPLE
// {
// "query": "mutation ($amount: Float!, $description: String!, $receipt: Upload!) {expenseClaim(expense: {amount: $amount, description: $description, receipt: $receipt}) {id}}",
// "query": "mutation ($amount: Float!, $invoice: Upload!) {expenseClaim(expense: {amount: $amount, invoice: $invoice}) {id flow}}",
// "variables": {
// "amount": 10,
// "description": "Hello World!",
Expand Down
38 changes: 37 additions & 1 deletion backend/src/graphql/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ module.exports = gql`
login(email: String!, password: String!): User! @guest
expenseClaim(expense: Expense!): Transaction! @auth
updateProfile(user: UserUpdateInput!): User! @auth
uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth
}
type Success {
status: Boolean!
Expand Down Expand Up @@ -45,6 +46,8 @@ module.exports = gql`

type Transaction {
id: ID!
category: Category
company: Company
flow: Flow!
state: State!
user: User!
Expand All @@ -57,6 +60,20 @@ module.exports = gql`
type: TransactionType!
}

type Category {
id: ID!
name: String!
}

type Company {
name: String!
email: String
phone: String
VAT: String
bankDetails: BankDetails
address: Address
}

#inputs
input AdressInput {
street: String!
Expand Down Expand Up @@ -89,12 +106,31 @@ module.exports = gql`
input Expense {
amount: Float!
date: String
expDate: String
description: String!
receipt: Upload!
VAT: Int
}

input InvoiceUpload {
amount: Float!
date: String
category: String
company: CompanyInput
expDate: String
invoice: Upload!
VAT: Int
}

input CompanyInput {
id: ID
name: String
email: String
phone: String
VAT: String
bankDetails: BankDetailsInput
address: AdressInput
}

#enums
enum Flow {
IN
Expand Down
7 changes: 5 additions & 2 deletions backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const models = require('./models');
const db = require('./db');
const auth = require('./auth');

const validation = require('./lib/validation');

const PORT = process.env.PORT || 4000;

const app = express();
Expand All @@ -29,7 +31,7 @@ app.use(
const context = async ({ req, res }) => {
const user = await auth.loggedUser(req.cookies, models);
// adopting injection pattern to ease mocking
return { req, res, user, auth, models, cloudinary, db };
return { req, res, user, auth, models, cloudinary, db, validation };
};

const server = new ApolloServer({
Expand Down Expand Up @@ -69,5 +71,6 @@ module.exports = {
auth,
server,
app,
cloudinary
cloudinary,
validation
};
110 changes: 88 additions & 22 deletions backend/src/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ configure({
EXISTY_STRICT: true
});

const MAX_LENGTH = 500;
const MAX_LENGTH = process.env.STRING_MAX_CHAR || 500;

exports.validate = (data, rules, messages) => {
return new Promise((resolve, reject) => {
Expand All @@ -41,54 +41,102 @@ const minMessage = (field, validation, args) => {
const maxMessage = (field, validation, args) => TOO_LONG(field, args[0]);
const aboveMessage = (field, validation, args) => MUST_BE_ABOVE(field, args[0]);

const addressValidation = {
rules: {
'address.street': `required_with_any:address.city,address.country,address.zipCode|max:${MAX_LENGTH}`,
'address.city': `required_with_any:address.street,address.country,address.zipCode|max:${MAX_LENGTH}`,
'address.country': `required_with_any:address.street,address.city,address.zipCode|max:${MAX_LENGTH}`
},
messages: { required_with_any: requiredMessage, max: maxMessage }
};

const bankDetailsValidation = {
rules: {
'bankDetails.bic': `requiredIf:bankDetails.iban|max:${MAX_LENGTH}`,
'bankDetails.iban': `requiredIf:bankDetails.bic|max:${MAX_LENGTH}` // TODO change to IBAN format
},
messages: { requiredIf: requiredMessage, max: maxMessage }
};

const companyValidation = {
rules: {
'company.name': `required|max:${MAX_LENGTH}`,
'company.email': 'email',
'company.phone': `max:${MAX_LENGTH}`, // TODO change to regex
'company.VAT': `max:12`, // TODO change to regex ?
...addressValidation.rules,
...bankDetailsValidation.rules
},
messages: {
required: requiredMessage,
max: maxMessage,
email: WRONG_EMAIL_FORMAT,
...bankDetailsValidation.messages,
...addressValidation.messages
},
formatData: data => ({
...data,
email: data.email === null ? undefined : data.email,
address: { ...data.address },
bankDetails: { ...data.bankDetails }
})
};

// module.exports = companyValidation;

// const categoryValidation = {
// rules: {
// name: `required|max:${MAX_LENGTH}`
// },
// messages: {
// required: requiredMessage
// }
// };

// module.exports = categoryValidation;

exports.registerValidation = {
rules: {
email: 'email|required',
name: `required|max:${MAX_LENGTH}`,
password: `required|min:8|max:1000`,
street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`,
city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`,
country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`,
bic: `requiredIf:iban|max:${MAX_LENGTH}`,
iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format
...addressValidation.rules,
...bankDetailsValidation.rules
},
messages: {
required: requiredMessage,
requiredIf: requiredMessage,
required_with_any: requiredMessage,
...addressValidation.messages,
...bankDetailsValidation.messages,
min: minMessage,
max: maxMessage,
'email.email': WRONG_EMAIL_FORMAT
email: WRONG_EMAIL_FORMAT
},
formatData: data => {
return {
email: data.email,
name: data.name,
password: data.password,
...data.address,
...data.bankDetails
bankDetails: { ...data.bankDetails },
address: { ...data.address }
};
}
};

exports.updateProfileValidation = {
rules: {
email: 'min:1|email',
email: 'email',
name: `min:1|max:${MAX_LENGTH}`,
password: `min:8|max:1000`,
street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`,
city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`,
country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`,
bic: `requiredIf:iban|max:${MAX_LENGTH}`,
iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format
...addressValidation.rules,
...bankDetailsValidation.rules
},
messages: {
required: requiredMessage,
required_with_any: requiredMessage,
requiredIf: requiredMessage,
min: minMessage,
max: maxMessage,
'email.email': WRONG_EMAIL_FORMAT
email: WRONG_EMAIL_FORMAT,
...addressValidation.messages,
...bankDetailsValidation.messages
},
formatData: data => {
const formattedData = {};
Expand All @@ -99,8 +147,7 @@ exports.updateProfileValidation = {
else formattedData.name = '';
if (data.password !== null) formattedData.password = data.password;
else formattedData.password = '';

return { ...formattedData, ...data.bankDetails, ...data.address };
return { ...formattedData, bankDetails: { ...data.bankDetails }, address: { ...data.address } };
}
};

Expand All @@ -121,3 +168,22 @@ exports.expenseValidation = {
return { VAT: data.VAT, amount: data.amount, date: data.date, description: data.description };
}
};

exports.uploadInvoiceValidation = {
rules: {
VAT: 'above:-1',
amount: 'above:-1',
date: 'date',
expDate: 'date',
...companyValidation.rules,
'company.name': `min:1|max:${MAX_LENGTH}`
},
messages: {
above: aboveMessage,
date: INVALID_DATE_FORMAT,
min: minMessage,
max: maxMessage,
...companyValidation.messages
},
formatData: data => ({ ...data, company: companyValidation.formatData({ ...data.company }) })
};
12 changes: 12 additions & 0 deletions backend/src/models/Category.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const mongoose = require('mongoose');

const { Schema } = mongoose;

const categorySchema = new Schema({
name: {
type: String,
trim: true
}
});

module.exports = mongoose.model('Category', categorySchema);
28 changes: 28 additions & 0 deletions backend/src/models/Company.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const mongoose = require('mongoose');
const bankDetails = require('./bankDetails');
const address = require('./address');

const { Schema } = mongoose;

const companySchema = new Schema({
name: {
type: String,
trim: true
},
email: {
type: String,
trim: true
},
phone: {
type: String,
trim: true
},
VAT: {
type: String,
trim: true
},
bankDetails,
address
});

module.exports = mongoose.model('Company', companySchema);
6 changes: 4 additions & 2 deletions backend/src/models/Transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ const transactionSchema = new Schema({
type: String // should be ObjectId
},
company: {
type: String // should be ObjectId
type: Schema.Types.ObjectId,
ref: 'Company'
},
category: {
type: String // should be ObjectId
type: Schema.Types.ObjectId,
ref: 'Category'
},
user: {
type: Schema.Types.ObjectId,
Expand Down
Loading