diff --git a/backend/package-lock.json b/backend/package-lock.json
index d86891c..00b6303 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -707,7 +707,6 @@
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
"integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==",
- "dev": true,
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -1126,7 +1125,6 @@
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
"integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
- "dev": true,
"requires": {
"safer-buffer": "~2.1.0"
}
@@ -1134,8 +1132,7 @@
"assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
- "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
- "dev": true
+ "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
},
"assign-symbols": {
"version": "1.0.0",
@@ -1185,8 +1182,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
- "dev": true
+ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"atob": {
"version": "2.1.2",
@@ -1197,14 +1193,12 @@
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
- "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
- "dev": true
+ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
},
"aws4": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz",
- "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==",
- "dev": true
+ "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ=="
},
"axobject-query": {
"version": "2.0.2",
@@ -1397,7 +1391,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
- "dev": true,
"requires": {
"tweetnacl": "^0.14.3"
}
@@ -1585,8 +1578,7 @@
"buffer-from": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
- "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
- "dev": true
+ "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
},
"busboy": {
"version": "0.3.1",
@@ -1674,8 +1666,7 @@
"caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
- "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
- "dev": true
+ "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
"chalk": {
"version": "2.4.2",
@@ -1888,7 +1879,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz",
"integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==",
- "dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@@ -1916,6 +1906,18 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
+ "concat-stream": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+ "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+ "optional": true,
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^2.2.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"configstore": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz",
@@ -2114,7 +2116,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
- "dev": true,
"requires": {
"assert-plus": "^1.0.0"
}
@@ -2269,8 +2270,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
- "dev": true
+ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"delegates": {
"version": "1.0.0",
@@ -2359,7 +2359,6 @@
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
- "dev": true,
"requires": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
@@ -2436,6 +2435,12 @@
"is-symbol": "^1.0.2"
}
},
+ "es6-promise": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz",
+ "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==",
+ "optional": true
+ },
"escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -2913,8 +2918,7 @@
"extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
- "dev": true
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"extend-shallow": {
"version": "3.0.2",
@@ -3024,17 +3028,27 @@
}
}
},
+ "extract-zip": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz",
+ "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=",
+ "optional": true,
+ "requires": {
+ "concat-stream": "1.6.2",
+ "debug": "2.6.9",
+ "mkdirp": "0.5.1",
+ "yauzl": "2.4.1"
+ }
+ },
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
- "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
- "dev": true
+ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
},
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
- "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
- "dev": true
+ "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk="
},
"fast-diff": {
"version": "1.2.0",
@@ -3062,6 +3076,15 @@
"bser": "^2.0.0"
}
},
+ "fd-slicer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz",
+ "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=",
+ "optional": true,
+ "requires": {
+ "pend": "~1.2.0"
+ }
+ },
"figures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
@@ -3181,14 +3204,12 @@
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
- "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
- "dev": true
+ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
},
"form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
- "dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
@@ -3219,6 +3240,17 @@
"resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-2.0.1.tgz",
"integrity": "sha512-kyV2oaG1/pu9NPosfGACmBym6okgzyg6hEtA5LSUq0dGpGLe278MVfMwVnSHDA/OBcTCHkPNqWL9eIwbPN6dDg=="
},
+ "fs-extra": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz",
+ "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=",
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "jsonfile": "^2.1.0",
+ "klaw": "^1.0.0"
+ }
+ },
"fs-minipass": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz",
@@ -3854,7 +3886,6 @@
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
- "dev": true,
"requires": {
"assert-plus": "^1.0.0"
}
@@ -3951,8 +3982,7 @@
"graceful-fs": {
"version": "4.1.15",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz",
- "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==",
- "dev": true
+ "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA=="
},
"graphql": {
"version": "14.2.1",
@@ -4035,14 +4065,12 @@
"har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
- "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
- "dev": true
+ "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
},
"har-validator": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
"integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
- "dev": true,
"requires": {
"ajv": "^6.5.5",
"har-schema": "^2.0.0"
@@ -4113,6 +4141,16 @@
}
}
},
+ "hasha": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hasha/-/hasha-2.2.0.tgz",
+ "integrity": "sha1-eNfL/B5tZjA/55g3NlmEUXsvbuE=",
+ "optional": true,
+ "requires": {
+ "is-stream": "^1.0.1",
+ "pinkie-promise": "^2.0.0"
+ }
+ },
"hosted-git-info": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz",
@@ -4128,6 +4166,14 @@
"whatwg-encoding": "^1.0.1"
}
},
+ "html-pdf": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/html-pdf/-/html-pdf-2.2.0.tgz",
+ "integrity": "sha1-S8+Rwky1YOR6o/rP0DPg4b8kG5E=",
+ "requires": {
+ "phantomjs-prebuilt": "^2.1.4"
+ }
+ },
"http-errors": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
@@ -4144,7 +4190,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
- "dev": true,
"requires": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
@@ -4754,8 +4799,7 @@
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
- "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
- "dev": true
+ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
},
"is-symbol": {
"version": "1.0.2",
@@ -4768,8 +4812,7 @@
"is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
- "dev": true
+ "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
},
"is-windows": {
"version": "1.0.2",
@@ -4791,8 +4834,7 @@
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
- "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
- "dev": true
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
},
"isobject": {
"version": "3.0.1",
@@ -4803,8 +4845,7 @@
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
- "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
- "dev": true
+ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
},
"istanbul-api": {
"version": "2.1.6",
@@ -5521,8 +5562,7 @@
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
- "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
- "dev": true
+ "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
},
"jsdom": {
"version": "11.12.0",
@@ -5590,14 +5630,12 @@
"json-schema": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
- "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=",
- "dev": true
+ "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
},
"json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
- "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
- "dev": true
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
},
"json-stable-stringify-without-jsonify": {
"version": "1.0.1",
@@ -5608,8 +5646,7 @@
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
- "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
- "dev": true
+ "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"json5": {
"version": "2.1.0",
@@ -5628,6 +5665,15 @@
}
}
},
+ "jsonfile": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz",
+ "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=",
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"jsonwebtoken": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz",
@@ -5656,7 +5702,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
"integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
- "dev": true,
"requires": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
@@ -5697,12 +5742,27 @@
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.0.tgz",
"integrity": "sha512-6hHxsp9e6zQU8nXsP+02HGWXwTkOEw6IROhF2ZA28cYbUk4eJ6QbtZvdqZOdD9YPKghG3apk5eOCvs+tLl3lRg=="
},
+ "kew": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz",
+ "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=",
+ "optional": true
+ },
"kind-of": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz",
"integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==",
"dev": true
},
+ "klaw": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz",
+ "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=",
+ "optional": true,
+ "requires": {
+ "graceful-fs": "^4.1.9"
+ }
+ },
"kleur": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -6623,8 +6683,7 @@
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
- "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
- "dev": true
+ "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
},
"object-assign": {
"version": "4.1.1",
@@ -6981,11 +7040,41 @@
"pify": "^2.0.0"
}
},
+ "pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
+ "optional": true
+ },
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
- "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
- "dev": true
+ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+ },
+ "phantomjs-prebuilt": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.16.tgz",
+ "integrity": "sha1-79ISpKOWbTZHaE6ouniFSb4q7+8=",
+ "optional": true,
+ "requires": {
+ "es6-promise": "^4.0.3",
+ "extract-zip": "^1.6.5",
+ "fs-extra": "^1.0.0",
+ "hasha": "^2.2.0",
+ "kew": "^0.7.0",
+ "progress": "^1.1.8",
+ "request": "^2.81.0",
+ "request-progress": "^2.0.1",
+ "which": "^1.2.10"
+ },
+ "dependencies": {
+ "progress": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz",
+ "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=",
+ "optional": true
+ }
+ }
},
"pify": {
"version": "2.3.0",
@@ -6996,14 +7085,12 @@
"pinkie": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
- "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=",
- "dev": true
+ "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA="
},
"pinkie-promise": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
"integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=",
- "dev": true,
"requires": {
"pinkie": "^2.0.0"
}
@@ -7177,8 +7264,7 @@
"psl": {
"version": "1.1.31",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.1.31.tgz",
- "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==",
- "dev": true
+ "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw=="
},
"pstree.remy": {
"version": "1.1.6",
@@ -7199,8 +7285,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
- "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
- "dev": true
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"q": {
"version": "1.5.1",
@@ -7393,7 +7478,6 @@
"version": "2.88.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz",
"integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==",
- "dev": true,
"requires": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
@@ -7420,14 +7504,12 @@
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
- "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
- "dev": true
+ "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
"tough-cookie": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz",
"integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==",
- "dev": true,
"requires": {
"psl": "^1.1.24",
"punycode": "^1.4.1"
@@ -7435,6 +7517,15 @@
}
}
},
+ "request-progress": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-2.0.1.tgz",
+ "integrity": "sha1-XTa7V5YcZzqlt4jbyBQf3yO0Tgg=",
+ "optional": true,
+ "requires": {
+ "throttleit": "^1.0.0"
+ }
+ },
"request-promise-core": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz",
@@ -8049,7 +8140,6 @@
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
"integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
- "dev": true,
"requires": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
@@ -8492,6 +8582,12 @@
"integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=",
"dev": true
},
+ "throttleit": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
+ "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=",
+ "optional": true
+ },
"through": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
@@ -8640,7 +8736,6 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
- "dev": true,
"requires": {
"safe-buffer": "^5.0.1"
}
@@ -8648,8 +8743,7 @@
"tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
- "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
- "dev": true
+ "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
},
"type-check": {
"version": "0.3.2",
@@ -8669,6 +8763,12 @@
"mime-types": "~2.1.18"
}
},
+ "typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
+ "optional": true
+ },
"uglify-js": {
"version": "3.5.8",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.8.tgz",
@@ -8838,7 +8938,6 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
- "dev": true,
"requires": {
"punycode": "^2.1.0"
}
@@ -8907,7 +9006,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
- "dev": true,
"requires": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
@@ -8979,7 +9077,6 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
- "dev": true,
"requires": {
"isexe": "^2.0.0"
}
@@ -9224,6 +9321,15 @@
"decamelize": "^1.2.0"
}
},
+ "yauzl": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz",
+ "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=",
+ "optional": true,
+ "requires": {
+ "fd-slicer": "~1.0.1"
+ }
+ },
"yup": {
"version": "0.26.10",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.26.10.tgz",
diff --git a/backend/package.json b/backend/package.json
index 011646f..792355c 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -20,6 +20,7 @@
"dotenv": "^7.0.0",
"express": "^4.16.4",
"graphql": "^14.1.1",
+ "html-pdf": "^2.2.0",
"indicative": "^5.0.8",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.4.19"
diff --git a/backend/src/constants.js b/backend/src/constants.js
new file mode 100644
index 0000000..3a61baa
--- /dev/null
+++ b/backend/src/constants.js
@@ -0,0 +1,18 @@
+exports.TR_FLOW = {
+ IN: 'IN',
+ OUT: 'OUT'
+};
+
+exports.TR_TYPE = {
+ INVOICE: 'INVOICE',
+ EXPENSE: 'EXPENSE'
+};
+
+exports.STORAGE_PATH = {
+ INVOICE_PENDING: '/invoices/pending',
+ INVOICE_PAID: '/invoices/paid',
+ INVOICE_REJECTED: '/invoices/rejected',
+ EXPENSE_PENDING: '/expenses/pending',
+ EXPENSE_PAID: '/expenses/paid',
+ EXPENSE_REJECTED: '/expenses/rejected'
+};
diff --git a/backend/src/db.js b/backend/src/db.js
index bda52a2..dec81a7 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -12,6 +12,8 @@ module.exports.connect = async () => {
await mongoose.connection.createCollection('users');
await mongoose.connection.createCollection('transactions');
await mongoose.connection.createCollection('categories');
+ await mongoose.connection.createCollection('companies');
+ await mongoose.connection.createCollection('counters');
console.log('Collections created successfully!');
})
.catch(err => {
diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js
index 79da88c..edf5505 100644
--- a/backend/src/graphql/resolvers/mutations.js
+++ b/backend/src/graphql/resolvers/mutations.js
@@ -1,11 +1,28 @@
-const {
- validate,
- registerValidation,
- updateProfileValidation,
- expenseValidation
-} = require('../../lib/validation');
-
-const store = (file, tags, folder, cloudinary) =>
+const saveOrRetrieveCompany = async (company, Company, upsert) => {
+ if (!company) return null;
+ const { name, id } = company;
+ if (!name && !id) return null;
+ if (company.id) {
+ return Company.findById(id);
+ }
+ if (company) {
+ return Company.findOneAndUpdate({ name }, company, { new: true, upsert });
+ }
+ return null;
+};
+
+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 z = '0';
+ return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`;
+};
+
+const store = (file, tags, folder, cloudinary, generated) =>
new Promise((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => {
if (image) {
@@ -13,11 +30,16 @@ const store = (file, tags, folder, cloudinary) =>
}
return reject(err);
});
- file.createReadStream().pipe(uploadStream);
+ if (!generated) file.createReadStream().pipe(uploadStream);
+ else file.pipe(uploadStream);
});
module.exports = {
- register: async (_, { user }, { models: { User } }) => {
+ register: async (
+ _,
+ { user },
+ { models: { User }, validation: { registerValidation, validate } }
+ ) => {
const { formatData, rules, messages } = registerValidation;
await validate(formatData({ ...user }), rules, messages);
@@ -32,7 +54,14 @@ module.exports = {
expenseClaim: async (
unused,
{ expense },
- { user, models: { Transaction, User }, cloudinary, db }
+ {
+ user,
+ models: { Transaction, User },
+ cloudinary,
+ db,
+ constants: { TR_TYPE, TR_FLOW, STORAGE_PATH },
+ validation: { expenseValidation, validate }
+ }
) => {
expense.date = expense.date || Date.now();
@@ -40,15 +69,20 @@ module.exports = {
await validate(formatData({ ...expense }), rules, messages);
expense.user = user.id;
- expense.flow = 'IN';
- expense.type = 'EXPENSE';
+ expense.flow = TR_FLOW.IN;
+ expense.type = TR_TYPE.EXPENSE;
const receipt = await expense.receipt;
const session = await db.startSession();
let tr;
try {
// upload the file to cloudinary
- const file = await store(receipt, 'expense receipt', '/expenses/pending/', cloudinary);
+ const file = await store(
+ receipt,
+ 'expense receipt',
+ STORAGE_PATH.EXPENSE_PENDING,
+ cloudinary
+ );
expense.file = file.secure_url;
const opts = { session };
@@ -76,7 +110,11 @@ module.exports = {
}
return tr;
},
- updateProfile: async (_, args, { user, models: { User } }) => {
+ updateProfile: async (
+ _,
+ args,
+ { user, models: { User }, validation: { updateProfileValidation, validate } }
+ ) => {
const { formatData, rules, messages } = updateProfileValidation;
await validate(formatData({ ...args.user }), rules, messages);
const { email } = args.user;
@@ -87,15 +125,81 @@ module.exports = {
}
}
return User.findOneAndUpdate({ _id: user.id }, args.user, { new: true });
+ },
+ uploadInvoice: async (
+ root,
+ { invoice },
+ {
+ models: { Transaction, Company, Category },
+ cloudinary,
+ constants: { TR_TYPE, TR_FLOW, STORAGE_PATH },
+ validation: { uploadInvoiceValidation, validate }
+ }
+ ) => {
+ 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)) {
+ 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 = TR_FLOW.IN;
+ invoice.type = TR_TYPE.INVOICE;
+ invoice.date = invoice.date || Date.now();
+ invoice.invoice = await invoice.invoice;
+
+ const file = await store(invoice.invoice, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary);
+ invoice.file = file.secure_url;
+
+ return new Transaction(invoice).save();
+ },
+ generateInvoice: async (
+ root,
+ { invoice },
+ {
+ models: { Transaction, Company, Counter },
+ cloudinary,
+ constants: { TR_TYPE, TR_FLOW, STORAGE_PATH },
+ validation: { generateInvoiceValidation, validate },
+ generateInvoicePDF
+ }
+ ) => {
+ 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;
+
+ // validate the inputs with the retrieved company, will throw if any required element is missing
+ await validate(formatData(invoice), rules, messages);
+ invoice.type = TR_TYPE.INVOICE;
+ invoice.flow = TR_FLOW.OUT;
+ invoice.date = Date.now();
+
+ const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter);
+ invoice.ref = noInvoice;
+
+ let file = await generateInvoicePDF(
+ invoice.details,
+ {
+ date: invoice.date,
+ VAT: invoice.VAT,
+ noInvoice
+ },
+ invoice.company
+ );
+
+ file = await store(file, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary, true);
+ invoice.file = file.secure_url;
+
+ return new Transaction(invoice).save();
}
};
-
-// TODO REMOVE EXAMPLE
-// {
-// "query": "mutation ($amount: Float!, $description: String!, $receipt: Upload!) {expenseClaim(expense: {amount: $amount, description: $description, receipt: $receipt}) {id}}",
-// "variables": {
-// "amount": 10,
-// "description": "Hello World!",
-// "receipt": null
-// }
-// }
diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js
index a7a31e1..8e95b8c 100644
--- a/backend/src/graphql/types.js
+++ b/backend/src/graphql/types.js
@@ -18,6 +18,8 @@ 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
+ generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth
}
type Success {
status: Boolean!
@@ -45,6 +47,8 @@ module.exports = gql`
type Transaction {
id: ID!
+ category: Category
+ company: Company
flow: Flow!
state: State!
user: User!
@@ -55,6 +59,21 @@ module.exports = gql`
file: String
VAT: Int
type: TransactionType!
+ ref: String
+ }
+
+ type Category {
+ id: ID!
+ name: String!
+ }
+
+ type Company {
+ name: String!
+ email: String
+ phone: String
+ VAT: String
+ bankDetails: BankDetails
+ address: Address
}
#inputs
@@ -89,12 +108,42 @@ 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
+ }
+
+ input GenerateInvoiceInput {
+ company: CompanyInput!
+ details: [GenerateInvoiceDetailsInput!]!
+ VAT: Int!
+ }
+
+ input GenerateInvoiceDetailsInput {
+ description: String!
+ amount: Float!
+ }
+
#enums
enum Flow {
IN
diff --git a/backend/src/index.js b/backend/src/index.js
index 705b532..bede14d 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -15,6 +15,10 @@ const models = require('./models');
const db = require('./db');
const auth = require('./auth');
+const validation = require('./lib/validation');
+const constants = require('./constants');
+const generateInvoicePDF = require('./invoiceFactory');
+
const PORT = process.env.PORT || 4000;
const app = express();
@@ -29,7 +33,18 @@ 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,
+ constants,
+ generateInvoicePDF
+ };
};
const server = new ApolloServer({
@@ -69,5 +84,8 @@ module.exports = {
auth,
server,
app,
- cloudinary
+ cloudinary,
+ validation,
+ constants,
+ generateInvoicePDF
};
diff --git a/backend/src/invoiceFactory.js b/backend/src/invoiceFactory.js
new file mode 100644
index 0000000..a736234
--- /dev/null
+++ b/backend/src/invoiceFactory.js
@@ -0,0 +1,150 @@
+const pdf = require('html-pdf');
+
+const options = { format: 'Letter' };
+
+const organization = {
+ name: 'Open Knowledge Belgium vzw',
+ address: {
+ street: 'Cantersteen 12',
+ city: 'Brussel',
+ country: 'Belgium',
+ zipCode: 1000
+ },
+ VAT: 'BE 0845.419.930',
+ iban: 'BE45 0688 9551 0289',
+ logo: 'https://res.cloudinary.com/dwyxk1pns/image/upload/v1557228337/assets/organization-logo.png'
+};
+
+const renderContactDetails = contact => {
+ return `${contact.name}
+ ${contact.address.street}
+ ${contact.address.zipCode} ${contact.address.city} ${contact.address.country}
VAT: ${
+ contact.VAT
+ }`;
+};
+
+module.exports = /* sender */ (details, metadata, receiver) => {
+ const total = details.reduce((acc, current) => acc + current.amount, 0);
+ const amountVAT = (total / 100) * metadata.VAT;
+ const totalInclVAT = total + amountVAT;
+ const html = `
+
+
+
+
+
+
+ Invoice
+ ${renderContactDetails(receiver)}
+
+ Invoice: ${metadata.noInvoice}
+ Date: ${new Date(metadata.date).toLocaleDateString('be-BE')}
+
+
+
+
+ | Description |
+ Amount |
+
+
+
+ ${details
+ .map(
+ detail => `
+ | ${detail.description} |
+ € ${detail.amount} |
+
`
+ )
+ .join('')}
+
+
+
+
+ | Total excl. VAT |
+ € ${total} |
+
+
+ | VAT ${metadata.VAT}% |
+ € ${amountVAT} |
+
+
+ | Total |
+ € ${totalInclVAT} |
+
+
+
+ Please pay this invoice by bank transfer, clearly stating the invoice number, to our account ${
+ organization.iban
+ } within 30 days after date of invoice clearly.
+
+
+
+
+ `;
+ return new Promise((resolve, reject) => {
+ pdf.create(html, options).toStream((err, res) => {
+ if (err) return reject(err);
+ return resolve(res);
+ });
+ });
+};
diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js
index de59635..ea2fe16 100644
--- a/backend/src/lib/validation.js
+++ b/backend/src/lib/validation.js
@@ -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) => {
@@ -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 = {};
@@ -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 } };
}
};
@@ -121,3 +168,44 @@ 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 }) })
+};
+
+exports.generateInvoiceValidation = {
+ rules: {
+ 'details.*.description': `required|max:${MAX_LENGTH}`,
+ 'details.*.amount': `above:-1|max:${MAX_LENGTH}`,
+ VAT: 'above:-1',
+ 'company.name': `required|max:${MAX_LENGTH}`,
+ 'company.address.street': `required|max:${MAX_LENGTH}`,
+ 'company.address.city': `required|max:${MAX_LENGTH}`,
+ 'company.address.zipCode': `required`,
+ 'company.address.country': `required|max:${MAX_LENGTH}`
+ },
+ messages: {
+ above: aboveMessage,
+ max: maxMessage,
+ required: requiredMessage
+ },
+ formatData: data => {
+ data.details = data.details.map(detail => ({ ...detail }));
+ return { ...data, details: [...data.details], company: data.company.toJSON() };
+ }
+};
diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js
new file mode 100644
index 0000000..12b0c2e
--- /dev/null
+++ b/backend/src/models/Category.js
@@ -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);
diff --git a/backend/src/models/Company.js b/backend/src/models/Company.js
new file mode 100644
index 0000000..451a014
--- /dev/null
+++ b/backend/src/models/Company.js
@@ -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);
diff --git a/backend/src/models/Counter.js b/backend/src/models/Counter.js
new file mode 100644
index 0000000..2a335d3
--- /dev/null
+++ b/backend/src/models/Counter.js
@@ -0,0 +1,10 @@
+const mongoose = require('mongoose');
+
+const { Schema } = mongoose;
+
+const CounterSchema = new Schema({
+ _id: String,
+ sequence: Number
+});
+
+module.exports = mongoose.model('Counter', CounterSchema);
diff --git a/backend/src/models/Transaction.js b/backend/src/models/Transaction.js
index 03dcae9..4fe0353 100644
--- a/backend/src/models/Transaction.js
+++ b/backend/src/models/Transaction.js
@@ -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,
@@ -46,7 +48,8 @@ const transactionSchema = new Schema({
type: {
type: String,
enum: ['EXPENSE', 'INVOICE']
- }
+ },
+ ref: String
});
module.exports = mongoose.model('Transaction', transactionSchema);
diff --git a/backend/src/models/user.js b/backend/src/models/User.js
similarity index 91%
rename from backend/src/models/user.js
rename to backend/src/models/User.js
index f9ce09e..dae711f 100644
--- a/backend/src/models/user.js
+++ b/backend/src/models/User.js
@@ -1,8 +1,8 @@
/* eslint-disable func-names */
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
-const Address = require('./address');
-const BankDetails = require('./bankDetails');
+const address = require('./address');
+const bankDetails = require('./bankDetails');
const { Schema } = mongoose;
@@ -20,8 +20,8 @@ const userSchema = new Schema({
trim: true,
lowercase: true
},
- address: Address,
- bankDetails: BankDetails,
+ address,
+ bankDetails,
expenses: [
{
type: Schema.Types.ObjectId,
diff --git a/backend/src/models/index.js b/backend/src/models/index.js
index 83b8087..d8ccd86 100644
--- a/backend/src/models/index.js
+++ b/backend/src/models/index.js
@@ -1,4 +1,7 @@
-const User = require('./user');
+const User = require('./User');
const Transaction = require('./Transaction');
+const Company = require('./Company');
+const Category = require('./Category');
+const Counter = require('./Counter');
-module.exports = { User, Transaction };
+module.exports = { User, Transaction, Company, Category, Counter };
diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap
index ac4abd9..2bf1242 100644
--- a/backend/src/tests/__snapshots__/e2e.test.js.snap
+++ b/backend/src/tests/__snapshots__/e2e.test.js.snap
@@ -23,6 +23,52 @@ Object {
}
`;
+exports[`Server - e2e Expense claim requires authenticated user 1`] = `
+Object {
+ "data": null,
+ "errors": Array [
+ Object {
+ "extensions": Object {
+ "code": "UNAUTHENTICATED",
+ },
+ "locations": Array [
+ Object {
+ "column": 5,
+ "line": 3,
+ },
+ ],
+ "message": "You must be logged in.",
+ "path": Array [
+ "expenseClaim",
+ ],
+ },
+ ],
+}
+`;
+
+exports[`Server - e2e Generate invoice requires authenticated user 1`] = `
+Object {
+ "data": null,
+ "errors": Array [
+ Object {
+ "extensions": Object {
+ "code": "UNAUTHENTICATED",
+ },
+ "locations": Array [
+ Object {
+ "column": 3,
+ "line": 2,
+ },
+ ],
+ "message": "You must be logged in.",
+ "path": Array [
+ "generateInvoice",
+ ],
+ },
+ ],
+}
+`;
+
exports[`Server - e2e Login fails on incorrect email 1`] = `
Object {
"data": null,
@@ -155,9 +201,78 @@ exports[`Server - e2e Register succeeds & returns lower case email 1`] = `
Object {
"data": Object {
"register": Object {
+ "address": null,
+ "bankDetails": null,
"email": "test@email.com",
"name": "Test Test",
},
},
}
`;
+
+exports[`Server - e2e Register succeeds & saves address and bankDetails 1`] = `
+Object {
+ "data": Object {
+ "register": Object {
+ "address": Object {
+ "city": "My city",
+ "country": "My country",
+ "street": "My street",
+ "zipCode": 1000,
+ },
+ "bankDetails": Object {
+ "bic": "MY BIC",
+ "iban": "MY IBAN",
+ },
+ "email": "test1@gmail.com",
+ "name": "Test Test",
+ },
+ },
+}
+`;
+
+exports[`Server - e2e Update profile requires authenticated user 1`] = `
+Object {
+ "data": null,
+ "errors": Array [
+ Object {
+ "extensions": Object {
+ "code": "UNAUTHENTICATED",
+ },
+ "locations": Array [
+ Object {
+ "column": 3,
+ "line": 2,
+ },
+ ],
+ "message": "You must be logged in.",
+ "path": Array [
+ "updateProfile",
+ ],
+ },
+ ],
+}
+`;
+
+exports[`Server - e2e Uplaod invoice requires authenticated user 1`] = `
+Object {
+ "data": null,
+ "errors": Array [
+ Object {
+ "extensions": Object {
+ "code": "UNAUTHENTICATED",
+ },
+ "locations": Array [
+ Object {
+ "column": 5,
+ "line": 3,
+ },
+ ],
+ "message": "You must be logged in.",
+ "path": Array [
+ "uploadInvoice",
+ ],
+ },
+ ],
+}
+`;
diff --git a/backend/src/tests/__snapshots__/integration.test.js.snap b/backend/src/tests/__snapshots__/integration.test.js.snap
index b3fc3bb..5edb6e8 100644
--- a/backend/src/tests/__snapshots__/integration.test.js.snap
+++ b/backend/src/tests/__snapshots__/integration.test.js.snap
@@ -1,5 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Authenticated user Generate invoice succeeds 1`] = `
+Object {
+ "data": Object {
+ "generateInvoice": Object {
+ "flow": "OUT",
+ "type": "INVOICE",
+ },
+ },
+ "errors": undefined,
+ "extensions": undefined,
+ "http": Object {
+ "headers": Headers {
+ Symbol(map): Object {},
+ },
+ },
+}
+`;
+
+exports[`Authenticated user Upload invoice succeeds 1`] = `
+Object {
+ "data": Object {
+ "uploadInvoice": Object {
+ "flow": "IN",
+ "type": "INVOICE",
+ },
+ },
+}
+`;
+
exports[`Authenticated user claims expenses succeeds 1`] = `
Object {
"data": Object {
diff --git a/backend/src/tests/__snapshots__/validations.test.js.snap b/backend/src/tests/__snapshots__/validations.test.js.snap
new file mode 100644
index 0000000..a8b1dbb
--- /dev/null
+++ b/backend/src/tests/__snapshots__/validations.test.js.snap
@@ -0,0 +1,44 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GENERATE INVOICE VALIDATION fails max length exceeded 1`] = `
+Array [
+ Object {
+ "details.0.description": "[details.*.description] must have a maximum of 50 characters.",
+ },
+ Object {
+ "company.name": "[company.name] must have a maximum of 50 characters.",
+ },
+ Object {
+ "company.address.street": "[company.address.street] must have a maximum of 50 characters.",
+ },
+ Object {
+ "company.address.city": "[company.address.city] must have a maximum of 50 characters.",
+ },
+ Object {
+ "company.address.country": "[company.address.country] must have a maximum of 50 characters.",
+ },
+]
+`;
+
+exports[`GENERATE INVOICE VALIDATION fails on missing required fields 1`] = `
+Array [
+ Object {
+ "details.0.description": "[details.*.description] is required (cannot be empty)",
+ },
+ Object {
+ "company.name": "[company.name] is required (cannot be empty)",
+ },
+ Object {
+ "company.address.street": "[company.address.street] is required (cannot be empty)",
+ },
+ Object {
+ "company.address.city": "[company.address.city] is required (cannot be empty)",
+ },
+ Object {
+ "company.address.zipCode": "[company.address.zipCode] is required (cannot be empty)",
+ },
+ Object {
+ "company.address.country": "[company.address.country] is required (cannot be empty)",
+ },
+]
+`;
diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js
index 5472a28..daaf91f 100644
--- a/backend/src/tests/e2e.test.js
+++ b/backend/src/tests/e2e.test.js
@@ -1,15 +1,29 @@
+/* eslint-disable no-unused-vars */
+const FormData = require('form-data');
+const fetch = require('node-fetch');
// import our production apollo-server instance
const { server, db } = require('../');
const { startTestServer, toPromise, populate, clean } = require('./utils');
-const { GET_ME, LOGIN_ME_IN, REGISTER, ALL_USERS } = require('./graphql/queryStrings');
+const {
+ GET_ME,
+ LOGIN_ME_IN,
+ REGISTER,
+ ALL_USERS,
+ UPDATE_PROFILE,
+ EXPENSE_CLAIM_WITHOUT_GQL,
+ INVOICE_UPLOAD_WITHOUT_GQL,
+ GENERATE_INVOICE
+} = require('./graphql/queryStrings');
const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } };
+const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' };
+const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' };
describe('Server - e2e', () => {
let stop;
let graphql;
-
+ let uri;
beforeAll(async () => {
await db.connect();
await populate();
@@ -26,6 +40,8 @@ describe('Server - e2e', () => {
stop = testServer.stop;
// eslint-disable-next-line prefer-destructuring
graphql = testServer.graphql;
+ // eslint-disable-next-line prefer-destructuring
+ uri = testServer.uri;
});
afterEach(async () => {
@@ -120,5 +136,104 @@ describe('Server - e2e', () => {
expect(res).toMatchSnapshot();
expect(res.data.register.email).toBe('test@email.com');
});
+
+ it('succeeds & saves address and bankDetails', async () => {
+ const res = await toPromise(
+ graphql({
+ query: REGISTER,
+ variables: {
+ user: {
+ ...testUser.user,
+ bankDetails,
+ address,
+ email: 'test1@gmail.com'
+ }
+ }
+ })
+ );
+ expect(res).toMatchSnapshot();
+ expect(res.data.register.bankDetails).toBeTruthy();
+ expect(res.data.register.address).toBeTruthy();
+ });
+ });
+
+ describe('Update profile', () => {
+ it('requires authenticated user', async () => {
+ const res = await toPromise(
+ graphql({
+ query: UPDATE_PROFILE,
+ variables: { user: {} }
+ })
+ );
+ expect(res).toMatchSnapshot();
+ });
+ });
+
+ describe('Expense claim', () => {
+ it('requires authenticated user', async () => {
+ // https://github.com/jaydenseric/graphql-upload/issues/125
+
+ const body = new FormData();
+
+ body.append(
+ 'operations',
+ JSON.stringify({
+ query: EXPENSE_CLAIM_WITHOUT_GQL,
+ variables: {
+ amount: 10,
+ description: 'Hello World!',
+ receipt: null
+ }
+ })
+ );
+ body.append('map', JSON.stringify({ '0': ['variables.receipt'] }));
+ body.append('0', 'a', { filename: 'a.pdf' });
+ let res = await fetch(uri, { method: 'POST', body });
+ res = await res.json();
+ expect(res).toMatchSnapshot();
+ });
+ });
+
+ describe('Uplaod invoice', () => {
+ it('requires authenticated user', async () => {
+ const body = new FormData();
+ body.append(
+ 'operations',
+ JSON.stringify({
+ query: INVOICE_UPLOAD_WITHOUT_GQL,
+ variables: {
+ amount: 10,
+ invoice: null
+ }
+ })
+ );
+ body.append('map', JSON.stringify({ '0': ['variables.invoice'] }));
+ body.append('0', 'a', { filename: 'a.pdf' });
+ let res = await fetch(uri, { method: 'POST', body });
+ res = await res.json();
+ expect(res).toMatchSnapshot();
+ });
+ });
+
+ describe('Generate invoice', () => {
+ it('requires authenticated user', async () => {
+ const res = await toPromise(
+ graphql({
+ query: GENERATE_INVOICE,
+ variables: {
+ invoice: {
+ VAT: 21,
+ company: {
+ name: 'MY SUPER COMP',
+ VAT: 'MY VAT',
+ address: { street: '', country: '', city: '', zipCode: 0 }
+ },
+ details: [{ description: '', amount: 200 }]
+ }
+ }
+ })
+ );
+ expect(res).toMatchSnapshot();
+ });
});
});
diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js
index 6c5336d..5b36a9a 100644
--- a/backend/src/tests/graphql/queryStrings.js
+++ b/backend/src/tests/graphql/queryStrings.js
@@ -24,6 +24,35 @@ module.exports.REGISTER = gql`
register(user: $user) {
name
email
+ bankDetails {
+ iban
+ bic
+ }
+ address {
+ street
+ city
+ zipCode
+ country
+ }
+ }
+ }
+`;
+
+module.exports.UPDATE_PROFILE = gql`
+ mutation updateProfile($user: UserUpdateInput!) {
+ updateProfile(user: $user) {
+ name
+ email
+ bankDetails {
+ iban
+ bic
+ }
+ address {
+ street
+ city
+ zipCode
+ country
+ }
}
}
`;
@@ -66,3 +95,24 @@ module.exports.EXPENSE_CLAIM_WITHOUT_GQL = `
}
}
`;
+
+module.exports.INVOICE_UPLOAD_WITHOUT_GQL = `
+ mutation($amount: Float!, $invoice: Upload!) {
+ uploadInvoice(invoice: { amount: $amount, invoice: $invoice }) {
+ id
+ type
+ flow
+ }
+ }
+`;
+
+module.exports.GENERATE_INVOICE = gql`
+ mutation($invoice: GenerateInvoiceInput!) {
+ generateInvoice(invoice: $invoice) {
+ id
+ type
+ flow
+ ref
+ }
+ }
+`;
diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js
index 0e5da75..ac6346a 100644
--- a/backend/src/tests/integration.test.js
+++ b/backend/src/tests/integration.test.js
@@ -3,7 +3,7 @@ const FormData = require('form-data');
const fetch = require('node-fetch');
const fs = require('fs');
const { auth } = require('./mocks/index');
-const { db, models, cloudinary } = require('../');
+const { db, models, cloudinary, validation, constants, generateInvoicePDF } = require('../');
const { constructTestServer, startTestServer, populate, clean } = require('./utils');
const {
@@ -11,10 +11,15 @@ const {
GET_ME,
REGISTER,
ALL_USERS,
- EXPENSE_CLAIM_WITHOUT_GQL
+ EXPENSE_CLAIM_WITHOUT_GQL,
+ UPDATE_PROFILE,
+ INVOICE_UPLOAD_WITHOUT_GQL,
+ GENERATE_INVOICE
} = require('./graphql/queryStrings');
const testUser = { user: { name: 'Test Test', email: 'test@email.com', password: 'testing0189' } };
+const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' };
+const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' };
describe('Authenticated user', () => {
let loggedUser;
@@ -90,6 +95,150 @@ describe('Authenticated user', () => {
expect(res.data.users.length).toBeGreaterThanOrEqual(3);
});
+ describe('update profile', () => {
+ let server;
+ let user;
+ beforeAll(async () => {
+ // authenticate another user to avoid crash in other tests
+ user = await models.User.findByEmail('mitchell@gmail.com');
+ server = constructTestServer({
+ context: () => {
+ return { models, user, auth, db, validation };
+ }
+ });
+ });
+
+ it('updates address without overwriting others fields', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: { user: { address } }
+ });
+ expect(res.data.updateProfile.address).toEqual(address);
+ expect(res.data.updateProfile.email).toBeTruthy();
+ });
+
+ it('updates bank details without overwriting others fields', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: { user: { bankDetails } }
+ });
+ expect(res.data.updateProfile.bankDetails).toEqual(bankDetails);
+ expect(res.data.updateProfile.address).toEqual(address);
+ });
+
+ it('updates email without overwritting others fields', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: { user: { email: 'johnny@john.com' } }
+ });
+ expect(res.data.updateProfile.email).toEqual('johnny@john.com');
+ expect(res.data.updateProfile.address).toEqual(address);
+ });
+
+ it('fails if email already exists', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: { user: { email: 'johnny@john.com' } }
+ });
+ expect(res.errors).toBeTruthy();
+ });
+
+ it('updates name without overwritting others fields', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: { user: { name: 'Johnny John' } }
+ });
+ expect(res.data.updateProfile.name).toEqual('Johnny John');
+ expect(res.data.updateProfile.address).toEqual(address);
+ });
+
+ it('can update all the fields at once', async () => {
+ const addr = {
+ street: 'Next street',
+ city: 'Next city',
+ zipCode: 1001,
+ country: 'Next Country'
+ };
+ const bd = { iban: 'BE098483992', bic: 'BPTOZE223' };
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: UPDATE_PROFILE,
+ variables: {
+ user: {
+ email: user.email,
+ name: user.name,
+ password: 'testing343',
+ address: addr,
+ bankDetails: bd
+ }
+ }
+ });
+ expect(res.data.updateProfile.name).toEqual(user.name);
+ expect(res.data.updateProfile.email).toEqual(user.email);
+ expect(res.data.updateProfile.address).toEqual(addr);
+ expect(res.data.updateProfile.bankDetails).toEqual(bd);
+ });
+ });
+
+ describe('Generate invoice', () => {
+ let server;
+ let stop;
+ beforeAll(() => {
+ server = constructTestServer({
+ context: () => {
+ return {
+ models,
+ user: loggedUser,
+ auth,
+ cloudinary,
+ db,
+ validation,
+ constants,
+ generateInvoicePDF
+ };
+ }
+ });
+ });
+
+ beforeEach(async () => {
+ const testServer = await startTestServer(server);
+ // eslint-disable-next-line prefer-destructuring
+ stop = testServer.stop;
+ });
+
+ afterEach(async () => {
+ stop();
+ });
+
+ it('succeeds', async () => {
+ const { mutate } = createTestClient(server);
+ const res = await mutate({
+ mutation: GENERATE_INVOICE,
+ variables: {
+ invoice: {
+ VAT: 21,
+ company: {
+ name: 'MY SUPER COMP',
+ VAT: 'MY VAT',
+ address
+ },
+ details: [{ description: 'My description', amount: 200 }]
+ }
+ }
+ });
+ expect(res.data.generateInvoice.id).toBeTruthy();
+ expect(res.data.generateInvoice.ref).toBeTruthy();
+ delete res.data.generateInvoice.id;
+ delete res.data.generateInvoice.ref;
+ expect(res).toMatchSnapshot();
+ });
+ });
+
describe('claims expenses', () => {
let uri;
let server;
@@ -97,7 +246,7 @@ describe('Authenticated user', () => {
beforeAll(() => {
server = constructTestServer({
context: () => {
- return { models, user: loggedUser, auth, cloudinary, db };
+ return { models, user: loggedUser, auth, cloudinary, db, validation, constants };
}
});
});
@@ -141,9 +290,60 @@ describe('Authenticated user', () => {
let res = await fetch(uri, { method: 'POST', body });
res = await res.json();
expect(res.data.expenseClaim.user.expenses.includes(res.data.expenseClaim.id)).toBeTruthy();
+ expect(res.data.expenseClaim.type).toBe(constants.TR_TYPE.EXPENSE);
+ expect(res.data.expenseClaim.flow).toBe(constants.TR_FLOW.IN);
delete res.data.expenseClaim.id;
delete res.data.expenseClaim.user.expenses;
expect(res).toMatchSnapshot();
});
});
+
+ describe('Upload invoice', () => {
+ let uri;
+ let server;
+ let stop;
+ beforeAll(() => {
+ server = constructTestServer({
+ context: () => {
+ return { models, user: loggedUser, auth, cloudinary, db, validation, constants };
+ }
+ });
+ });
+
+ beforeEach(async () => {
+ const testServer = await startTestServer(server);
+ // eslint-disable-next-line prefer-destructuring
+ stop = testServer.stop;
+ // eslint-disable-next-line prefer-destructuring
+ uri = testServer.uri;
+ });
+
+ afterEach(async () => {
+ stop();
+ });
+
+ it('succeeds', async () => {
+ const body = new FormData();
+ body.append(
+ 'operations',
+ JSON.stringify({
+ query: INVOICE_UPLOAD_WITHOUT_GQL,
+ variables: {
+ amount: 10,
+ invoice: null
+ }
+ })
+ );
+ body.append('map', JSON.stringify({ '0': ['variables.invoice'] }));
+ const file = fs.createReadStream(`${__dirname}/a.pdf`);
+ body.append('0', file);
+
+ let res = await fetch(uri, { method: 'POST', body });
+ res = await res.json();
+ expect(res.data.uploadInvoice.type).toBe(constants.TR_TYPE.INVOICE);
+ expect(res.data.uploadInvoice.flow).toBe(constants.TR_FLOW.IN);
+ delete res.data.uploadInvoice.id;
+ expect(res).toMatchSnapshot();
+ });
+ });
});
diff --git a/backend/src/tests/utils.js b/backend/src/tests/utils.js
index bb3b5c3..a21d16c 100644
--- a/backend/src/tests/utils.js
+++ b/backend/src/tests/utils.js
@@ -85,9 +85,11 @@ module.exports.startTestServer = startTestServer;
// clean database
const clean = async () => {
- const { User, Transaction } = models;
+ const { User, Transaction, Company, Counter } = models;
await User.deleteMany({});
await Transaction.deleteMany({});
+ await Company.deleteMany({});
+ await Counter.deleteMany({});
};
// populate database
diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js
new file mode 100644
index 0000000..42f0d4b
--- /dev/null
+++ b/backend/src/tests/validations.test.js
@@ -0,0 +1,462 @@
+const { UserInputError } = require('apollo-server-express');
+const {
+ validation: {
+ registerValidation,
+ updateProfileValidation,
+ expenseValidation,
+ uploadInvoiceValidation,
+ generateInvoiceValidation,
+ validate
+ }
+} = require('../');
+
+/**
+ * Test of input validation module
+ *
+ * Notes:
+ * 1. GraphQL will detect undefined values and reject them
+ * 2. GraphQL won't allow null for Numbers (Int/Float)
+ * 3. GraphQL allows null value for strings
+ * 4. GraphQL will make sure required fields have values (but allows empty string)
+ */
+const address = {
+ street: 'My Street 31',
+ city: 'My City',
+ country: 'My Country',
+ zipCode: 1000
+};
+const bankDetails = {
+ iban: 'MY IBAN',
+ bic: 'MY BIC'
+};
+const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6';
+
+const addressExceedingMaxLength = {
+ street: FIFTYONECHARSSTR,
+ city: FIFTYONECHARSSTR,
+ country: FIFTYONECHARSSTR,
+ zipCode: 1000
+};
+
+describe('EXPENSE CLAIM VALIDATION', () => {
+ const { messages, rules } = expenseValidation;
+ const expense = {
+ VAT: 21,
+ amount: 10,
+ date: Date.now(),
+ description: 'Hello World!'
+ };
+
+ it('will pass validation (minimum required fields)', async () => {
+ await validate({ description: expense.description, amount: expense.amount }, rules, messages);
+ });
+
+ it('will pass validation (all required fields)', async () => {
+ await validate(expense, rules, messages);
+ });
+
+ it('fails if description not set', async () => {
+ try {
+ // {} is equivalent to description = null
+ await validate({}, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].description).toBeTruthy();
+ } else throw error;
+ }
+ try {
+ await validate({ description: undefined }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].description).toBeTruthy();
+ } else throw error;
+ }
+ try {
+ await validate({ description: '' }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].description).toBeTruthy();
+ } else throw error;
+ }
+ });
+
+ it('fails on max length exceeded', async () => {
+ try {
+ await validate({ description: FIFTYONECHARSSTR }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].description).toBeTruthy();
+ } else throw error;
+ }
+ });
+
+ it('fails on max length exceeded', async () => {
+ try {
+ await validate({ description: FIFTYONECHARSSTR }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].description).toBeTruthy();
+ } else throw error;
+ }
+ });
+
+ it('fails on invalid date', async () => {
+ try {
+ await validate({ description: expense.description, date: 'fefle' }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].date).toBeTruthy();
+ } else throw error;
+ }
+ });
+});
+describe('REGISTER VALIDATION', () => {
+ const { rules, messages } = registerValidation;
+ const user = {
+ email: 'test@mail.com',
+ name: 'Test Test',
+ password: 'azerty1234',
+ address,
+ bankDetails
+ };
+ it("doesn't throw (minimal config)", async () => {
+ const data = {
+ email: user.email,
+ name: user.name,
+ password: user.password
+ };
+
+ await validate(data, rules, messages);
+ });
+
+ it("doesn't throw (with address)", async () => {
+ const data = {
+ ...user,
+ bankDetails: {}
+ };
+ await validate(data, rules, messages);
+ });
+
+ it("doesn't throw (with bankDetails)", async () => {
+ const data = {
+ ...user,
+ address: {}
+ };
+ await validate(data, rules, messages);
+ });
+
+ it("doesn't throw (full config)", async () => {
+ const data = user;
+ await validate(data, rules, messages);
+ });
+
+ it('throws on missing required field', async () => {
+ const data = {};
+ try {
+ await validate(data, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0].email).toBeTruthy();
+ expect(err[1].email).toBeTruthy();
+ expect(err[2].name).toBeTruthy();
+ expect(err[3].password).toBeTruthy();
+ } else throw error;
+ }
+ });
+
+ describe('throws if password is not matching requirements', () => {
+ it('throws if password less than minimal characters', async () => {
+ try {
+ await validate({ ...user, password: '1234567' }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) expect(true).toBe(true);
+ else throw error;
+ }
+ });
+ });
+
+ it('throws if one of bankDetails field is missing while the other is present', async () => {
+ const data = {
+ ...user,
+ bankDetails: {
+ iban: 'MY IBAN'
+ }
+ };
+ try {
+ await validate(data, rules, messages);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0]['bankDetails.bic']).toBeTruthy();
+ } else throw error;
+ }
+ try {
+ await validate({ ...data, bankDetails: { bic: 'MY BIC' } }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err[0]['bankDetails.iban']).toBeTruthy();
+ } else throw error;
+ }
+ });
+
+ it('throws if any of address fields are missing while one of them is present', async () => {
+ // street is present
+ try {
+ await validate({ ...user, address: { street: 'My street' } }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(2);
+ } else throw error;
+ }
+ // city is present
+ try {
+ await validate({ ...user, address: { city: 'My city' } }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(2);
+ } else throw error;
+ }
+ // zipCode is present
+ try {
+ await validate({ ...user, address: { zipCode: 1000 } }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(3);
+ } else throw error;
+ }
+ // country is present
+ try {
+ await validate({ ...user, address: { country: 'My country' } }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(2);
+ } else throw error;
+ }
+ });
+
+ it('throws when length over max', async () => {
+ const data = {
+ ...user,
+ name: FIFTYONECHARSSTR,
+ address: {
+ city: FIFTYONECHARSSTR,
+ country: FIFTYONECHARSSTR,
+ street: FIFTYONECHARSSTR
+ },
+ bankDetails: {
+ bic: FIFTYONECHARSSTR,
+ iban: FIFTYONECHARSSTR
+ }
+ };
+ try {
+ await validate(data, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(6);
+ } else throw error;
+ }
+ });
+});
+describe('UPDATE PROFILE VALIDATION', () => {
+ // most cases have been tested in REGISTER VALIDATION
+ const { formatData, messages, rules } = updateProfileValidation;
+ it('does not require any field', async () => {
+ await validate(formatData({}), rules, messages);
+ });
+
+ it('throws on empty string', async () => {
+ try {
+ await validate(formatData({ email: '', name: '', password: '' }), rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(3);
+ } else throw error;
+ }
+ });
+
+ it('throws on null value', async () => {
+ try {
+ await validate(formatData({ email: null, name: null, password: null }), rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(3);
+ } else throw error;
+ }
+ });
+});
+describe('UPLOAD INVOICE VALIDATION', () => {
+ const { formatData, messages, rules } = uploadInvoiceValidation;
+ const invoice = {
+ company: {
+ address,
+ bankDetails,
+ name: 'OKBE',
+ VAT: 'BE0000000000',
+ phone: '0483473742'
+ },
+ phone: '0483473741',
+ VAT: 21,
+ amount: 10,
+ date: Date.now(),
+ expDate: Date.now()
+ };
+
+ it('passes validation with minial required fields', async () => {
+ await validate(formatData({}), rules, messages);
+ });
+
+ it('passes validation with full fields', async () => {
+ await validate(formatData(invoice), rules, messages);
+ });
+
+ it('fails on invalid date', async () => {
+ try {
+ await validate(formatData({ date: 'eeee', expDate: 'eeee' }), rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(2);
+ } else throw error;
+ }
+ });
+
+ it('fails on negative value', async () => {
+ try {
+ await validate(formatData({ amount: -1, VAT: -1 }), rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(2);
+ } else throw error;
+ }
+ });
+
+ // TODO make this describe a root
+ describe('COMPANY VALIDATION', () => {
+ // Address & BankDetails are tested in REGISTER VALIDATION
+ it('passes with minimal required fields', async () => {
+ await validate(formatData({ company: { name: 'OKBE' } }), rules, messages);
+ });
+ it('passes with full fields', async () => {
+ await validate(formatData({ company: invoice.company }), rules, messages);
+ });
+ it('fails on max length exceeded', async () => {
+ try {
+ await validate(
+ formatData({
+ company: {
+ name: FIFTYONECHARSSTR,
+ VAT: `${invoice.company.VAT}1`,
+ phone: FIFTYONECHARSSTR
+ }
+ }),
+ rules,
+ messages
+ );
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const err = JSON.parse(error.message);
+ expect(err).toHaveLength(3);
+ } else throw error;
+ }
+ });
+ });
+});
+
+describe('GENERATE INVOICE VALIDATION', () => {
+ const { messages, rules } = generateInvoiceValidation;
+ const invoice = {
+ VAT: 21,
+ company: {
+ name: 'MY SUPER COMP',
+ VAT: 'MY VAT',
+ address
+ },
+ details: [{ description: 'My description', amount: 200 }]
+ };
+ it('passes validation', async () => {
+ await validate(invoice, rules, messages);
+ });
+ it('fails on missing required fields', async () => {
+ try {
+ await validate({ details: [{}] }, rules, messages);
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ expect(JSON.parse(error.message)).toMatchSnapshot();
+ } else throw error;
+ }
+ });
+ it('fails on negative value', async () => {
+ try {
+ await validate(
+ { ...invoice, VAT: -1, details: [{ description: 'My description', amount: -1 }] },
+ rules,
+ messages
+ );
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ const message = JSON.parse(error.message);
+ expect(message[1].VAT).toBeTruthy();
+ expect(message[0]['details.0.amount']).toBeTruthy();
+ } else throw error;
+ }
+ });
+ it('fails max length exceeded', async () => {
+ try {
+ await validate(
+ {
+ VAT: 10,
+ details: [{ description: FIFTYONECHARSSTR, amount: 300 }],
+ company: {
+ name: FIFTYONECHARSSTR,
+ address: addressExceedingMaxLength,
+ VAT: FIFTYONECHARSSTR
+ }
+ },
+ rules,
+ messages
+ );
+ expect(false).toBe(true);
+ } catch (error) {
+ if (error instanceof UserInputError) {
+ expect(JSON.parse(error.message)).toMatchSnapshot();
+ } else throw error;
+ }
+ });
+});