From 9689480b67be5c2b620df8764ed35c03ede0b4ac Mon Sep 17 00:00:00 2001 From: Joe Gallegos Date: Thu, 16 Nov 2017 18:21:47 -0700 Subject: [PATCH] Initial commit --- .gitignore | 4 + .meteor/.finished-upgraders | 17 + .meteor/.gitignore | 1 + .meteor/.id | 7 + .meteor/packages | 35 + .meteor/platforms | 2 + .meteor/release | 1 + .meteor/versions | 104 + client/main.html | 13 + client/main.js | 1 + imports/api/Documents/Documents.js | 51 + imports/api/Documents/methods.js | 53 + imports/api/Documents/server/publications.js | 13 + imports/api/Invoices/Invoices.js | 131 + imports/api/Invoices/methods.js | 134 + imports/api/Invoices/server/publications.js | 18 + imports/api/OAuth/server/methods.js | 30 + imports/api/Recipients/Recipients.js | 66 + imports/api/Recipients/methods.js | 28 + imports/api/Recipients/server/publications.js | 12 + imports/api/Users/server/edit-profile.js | 32 + imports/api/Users/server/methods.js | 37 + imports/api/Users/server/publications.js | 11 + .../api/Users/server/send-welcome-email.js | 25 + imports/api/Utility/server/methods.js | 11 + imports/modules/currency-conversions.js | 13 + imports/modules/get-oauth-profile.js | 42 + imports/modules/parse-markdown.js | 8 + imports/modules/rate-limit.js | 11 + .../modules/server/generate-invoice-as-pdf.js | 66 + imports/modules/server/get-private-file.js | 3 + .../server/handlebars-email-to-html.js | 11 + .../server/handlebars-email-to-text.js | 10 + imports/modules/server/send-email.js | 29 + imports/modules/stripe-checkout.js | 13 + imports/modules/validate.js | 4 + imports/startup/client/index.js | 8 + .../server/accounts/email-templates.js | 59 + imports/startup/server/accounts/index.js | 3 + imports/startup/server/accounts/oauth.js | 13 + .../startup/server/accounts/on-create-user.js | 9 + imports/startup/server/api.js | 16 + imports/startup/server/email.js | 3 + imports/startup/server/fixtures.js | 61 + imports/startup/server/index.js | 4 + .../AccountPageFooter/AccountPageFooter.js | 16 + .../AccountPageFooter/AccountPageFooter.scss | 7 + .../components/Authenticated/Authenticated.js | 23 + .../AuthenticatedNavigation.js | 31 + imports/ui/components/Content/Content.js | 16 + imports/ui/components/Content/Content.scss | 36 + .../DateTimePicker/DateTimePicker.js | 14 + .../DocumentEditor/DocumentEditor.js | 97 + imports/ui/components/Footer/Footer.js | 27 + imports/ui/components/Footer/Footer.scss | 55 + imports/ui/components/Icon/Icon.js | 10 + imports/ui/components/InputHint/InputHint.js | 16 + .../ui/components/InputHint/InputHint.scss | 9 + .../components/InvoiceEditor/InvoiceEditor.js | 282 + .../InvoiceEditor/InvoiceEditor.scss | 60 + imports/ui/components/Loading/Loading.js | 56 + .../ui/components/Navigation/Navigation.js | 32 + .../ui/components/Navigation/Navigation.scss | 6 + .../OAuthLoginButton/OAuthLoginButton.js | 60 + .../OAuthLoginButton/OAuthLoginButton.scss | 52 + .../OAuthLoginButtons/OAuthLoginButtons.js | 42 + .../OAuthLoginButtons/OAuthLoginButtons.scss | 20 + .../ui/components/PageHeader/PageHeader.js | 24 + .../ui/components/PageHeader/PageHeader.scss | 47 + imports/ui/components/Public/Public.js | 23 + .../PublicNavigation/PublicNavigation.js | 16 + .../RecipientEditor/RecipientEditor.js | 174 + .../RecipientEditor/RecipientEditor.scss | 33 + .../SelectRecipient/SelectRecipient.js | 46 + .../components/StaticInvoice/StaticInvoice.js | 304 + imports/ui/layouts/App/App.js | 118 + imports/ui/layouts/App/App.scss | 23 + imports/ui/pages/Documents/Documents.js | 82 + imports/ui/pages/Documents/Documents.scss | 3 + imports/ui/pages/EditDocument/EditDocument.js | 33 + .../ui/pages/EditRecipient/EditRecipient.js | 34 + imports/ui/pages/ExamplePage/ExamplePage.js | 14 + imports/ui/pages/Index/Index.js | 24 + imports/ui/pages/Index/Index.scss | 75 + imports/ui/pages/Invoices/Invoices.js | 72 + imports/ui/pages/Invoices/Invoices.scss | 3 + imports/ui/pages/Login/Login.js | 108 + imports/ui/pages/Logout/Logout.js | 32 + imports/ui/pages/Logout/Logout.scss | 57 + imports/ui/pages/NewDocument/NewDocument.js | 16 + imports/ui/pages/NewInvoice/NewInvoice.js | 21 + imports/ui/pages/NewRecipient/NewRecipient.js | 21 + imports/ui/pages/NotFound/NotFound.js | 12 + imports/ui/pages/Page/Page.js | 44 + imports/ui/pages/Page/Page.scss | 11 + imports/ui/pages/Privacy/Privacy.js | 14 + imports/ui/pages/Profile/Profile.js | 230 + imports/ui/pages/Profile/Profile.scss | 46 + imports/ui/pages/Recipients/Recipients.js | 48 + imports/ui/pages/Recipients/Recipients.scss | 3 + .../pages/RecoverPassword/RecoverPassword.js | 83 + .../ui/pages/ResetPassword/ResetPassword.js | 99 + imports/ui/pages/Signup/Signup.js | 157 + imports/ui/pages/Terms/Terms.js | 14 + imports/ui/pages/VerifyEmail/VerifyEmail.js | 43 + imports/ui/pages/ViewDocument/ViewDocument.js | 60 + imports/ui/pages/ViewInvoice/ViewInvoice.js | 43 + imports/ui/stylesheets/app.scss | 3 + .../ui/stylesheets/bootstrap-overrides.scss | 11 + imports/ui/stylesheets/colors.scss | 20 + imports/ui/stylesheets/forms.scss | 17 + imports/ui/stylesheets/mixins.scss | 23 + package-lock.json | 6566 +++++++++++++++++ package.json | 93 + private/email-templates/invoice.html | 332 + private/email-templates/invoice.txt | 10 + private/email-templates/reset-password.html | 322 + private/email-templates/reset-password.txt | 10 + private/email-templates/verify-email.html | 317 + private/email-templates/verify-email.txt | 8 + private/email-templates/welcome.html | 322 + private/email-templates/welcome.txt | 10 + private/pages/example-page.md | 5 + private/pages/privacy.md | 18 + private/pages/terms.md | 38 + public/apple-touch-icon-precomposed.png | Bin 0 -> 13563 bytes public/facebook.svg | 13 + public/favicon.png | Bin 0 -> 6030 bytes public/github.svg | 29 + public/google.svg | 15 + server/main.js | 1 + settings-development.json | 26 + 132 files changed, 12709 insertions(+) create mode 100644 .gitignore create mode 100644 .meteor/.finished-upgraders create mode 100644 .meteor/.gitignore create mode 100644 .meteor/.id create mode 100644 .meteor/packages create mode 100644 .meteor/platforms create mode 100644 .meteor/release create mode 100644 .meteor/versions create mode 100644 client/main.html create mode 100644 client/main.js create mode 100644 imports/api/Documents/Documents.js create mode 100644 imports/api/Documents/methods.js create mode 100644 imports/api/Documents/server/publications.js create mode 100644 imports/api/Invoices/Invoices.js create mode 100644 imports/api/Invoices/methods.js create mode 100644 imports/api/Invoices/server/publications.js create mode 100644 imports/api/OAuth/server/methods.js create mode 100644 imports/api/Recipients/Recipients.js create mode 100644 imports/api/Recipients/methods.js create mode 100644 imports/api/Recipients/server/publications.js create mode 100644 imports/api/Users/server/edit-profile.js create mode 100644 imports/api/Users/server/methods.js create mode 100644 imports/api/Users/server/publications.js create mode 100644 imports/api/Users/server/send-welcome-email.js create mode 100644 imports/api/Utility/server/methods.js create mode 100644 imports/modules/currency-conversions.js create mode 100644 imports/modules/get-oauth-profile.js create mode 100644 imports/modules/parse-markdown.js create mode 100644 imports/modules/rate-limit.js create mode 100644 imports/modules/server/generate-invoice-as-pdf.js create mode 100644 imports/modules/server/get-private-file.js create mode 100644 imports/modules/server/handlebars-email-to-html.js create mode 100644 imports/modules/server/handlebars-email-to-text.js create mode 100644 imports/modules/server/send-email.js create mode 100644 imports/modules/stripe-checkout.js create mode 100644 imports/modules/validate.js create mode 100644 imports/startup/client/index.js create mode 100644 imports/startup/server/accounts/email-templates.js create mode 100644 imports/startup/server/accounts/index.js create mode 100644 imports/startup/server/accounts/oauth.js create mode 100644 imports/startup/server/accounts/on-create-user.js create mode 100644 imports/startup/server/api.js create mode 100644 imports/startup/server/email.js create mode 100644 imports/startup/server/fixtures.js create mode 100644 imports/startup/server/index.js create mode 100644 imports/ui/components/AccountPageFooter/AccountPageFooter.js create mode 100644 imports/ui/components/AccountPageFooter/AccountPageFooter.scss create mode 100644 imports/ui/components/Authenticated/Authenticated.js create mode 100644 imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js create mode 100644 imports/ui/components/Content/Content.js create mode 100644 imports/ui/components/Content/Content.scss create mode 100644 imports/ui/components/DateTimePicker/DateTimePicker.js create mode 100644 imports/ui/components/DocumentEditor/DocumentEditor.js create mode 100644 imports/ui/components/Footer/Footer.js create mode 100644 imports/ui/components/Footer/Footer.scss create mode 100644 imports/ui/components/Icon/Icon.js create mode 100644 imports/ui/components/InputHint/InputHint.js create mode 100644 imports/ui/components/InputHint/InputHint.scss create mode 100644 imports/ui/components/InvoiceEditor/InvoiceEditor.js create mode 100644 imports/ui/components/InvoiceEditor/InvoiceEditor.scss create mode 100644 imports/ui/components/Loading/Loading.js create mode 100644 imports/ui/components/Navigation/Navigation.js create mode 100644 imports/ui/components/Navigation/Navigation.scss create mode 100644 imports/ui/components/OAuthLoginButton/OAuthLoginButton.js create mode 100644 imports/ui/components/OAuthLoginButton/OAuthLoginButton.scss create mode 100644 imports/ui/components/OAuthLoginButtons/OAuthLoginButtons.js create mode 100644 imports/ui/components/OAuthLoginButtons/OAuthLoginButtons.scss create mode 100644 imports/ui/components/PageHeader/PageHeader.js create mode 100644 imports/ui/components/PageHeader/PageHeader.scss create mode 100644 imports/ui/components/Public/Public.js create mode 100644 imports/ui/components/PublicNavigation/PublicNavigation.js create mode 100644 imports/ui/components/RecipientEditor/RecipientEditor.js create mode 100644 imports/ui/components/RecipientEditor/RecipientEditor.scss create mode 100644 imports/ui/components/SelectRecipient/SelectRecipient.js create mode 100644 imports/ui/components/StaticInvoice/StaticInvoice.js create mode 100644 imports/ui/layouts/App/App.js create mode 100644 imports/ui/layouts/App/App.scss create mode 100644 imports/ui/pages/Documents/Documents.js create mode 100644 imports/ui/pages/Documents/Documents.scss create mode 100644 imports/ui/pages/EditDocument/EditDocument.js create mode 100644 imports/ui/pages/EditRecipient/EditRecipient.js create mode 100644 imports/ui/pages/ExamplePage/ExamplePage.js create mode 100644 imports/ui/pages/Index/Index.js create mode 100644 imports/ui/pages/Index/Index.scss create mode 100644 imports/ui/pages/Invoices/Invoices.js create mode 100644 imports/ui/pages/Invoices/Invoices.scss create mode 100644 imports/ui/pages/Login/Login.js create mode 100644 imports/ui/pages/Logout/Logout.js create mode 100644 imports/ui/pages/Logout/Logout.scss create mode 100644 imports/ui/pages/NewDocument/NewDocument.js create mode 100644 imports/ui/pages/NewInvoice/NewInvoice.js create mode 100644 imports/ui/pages/NewRecipient/NewRecipient.js create mode 100644 imports/ui/pages/NotFound/NotFound.js create mode 100644 imports/ui/pages/Page/Page.js create mode 100644 imports/ui/pages/Page/Page.scss create mode 100644 imports/ui/pages/Privacy/Privacy.js create mode 100644 imports/ui/pages/Profile/Profile.js create mode 100644 imports/ui/pages/Profile/Profile.scss create mode 100644 imports/ui/pages/Recipients/Recipients.js create mode 100644 imports/ui/pages/Recipients/Recipients.scss create mode 100644 imports/ui/pages/RecoverPassword/RecoverPassword.js create mode 100644 imports/ui/pages/ResetPassword/ResetPassword.js create mode 100644 imports/ui/pages/Signup/Signup.js create mode 100644 imports/ui/pages/Terms/Terms.js create mode 100644 imports/ui/pages/VerifyEmail/VerifyEmail.js create mode 100644 imports/ui/pages/ViewDocument/ViewDocument.js create mode 100644 imports/ui/pages/ViewInvoice/ViewInvoice.js create mode 100644 imports/ui/stylesheets/app.scss create mode 100644 imports/ui/stylesheets/bootstrap-overrides.scss create mode 100644 imports/ui/stylesheets/colors.scss create mode 100644 imports/ui/stylesheets/forms.scss create mode 100644 imports/ui/stylesheets/mixins.scss create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 private/email-templates/invoice.html create mode 100644 private/email-templates/invoice.txt create mode 100644 private/email-templates/reset-password.html create mode 100644 private/email-templates/reset-password.txt create mode 100644 private/email-templates/verify-email.html create mode 100644 private/email-templates/verify-email.txt create mode 100644 private/email-templates/welcome.html create mode 100644 private/email-templates/welcome.txt create mode 100644 private/pages/example-page.md create mode 100644 private/pages/privacy.md create mode 100644 private/pages/terms.md create mode 100644 public/apple-touch-icon-precomposed.png create mode 100644 public/facebook.svg create mode 100644 public/favicon.png create mode 100644 public/github.svg create mode 100644 public/google.svg create mode 100644 server/main.js create mode 100644 settings-development.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da6ed88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +settings-demo.json +settings-staging.json +settings-production.json diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders new file mode 100644 index 0000000..910574c --- /dev/null +++ b/.meteor/.finished-upgraders @@ -0,0 +1,17 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package +1.4.3-split-account-service-packages +1.5-add-dynamic-import-package diff --git a/.meteor/.gitignore b/.meteor/.gitignore new file mode 100644 index 0000000..4083037 --- /dev/null +++ b/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/.meteor/.id b/.meteor/.id new file mode 100644 index 0000000..9c23f12 --- /dev/null +++ b/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +16be20efyo0qb53r01o diff --git a/.meteor/packages b/.meteor/packages new file mode 100644 index 0000000..62200af --- /dev/null +++ b/.meteor/packages @@ -0,0 +1,35 @@ +# Meteor packages used by this project, one per line. +# Check this file (and the other files in this directory) into your repository. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-base@1.1.0 # Packages every Meteor app needs to have +mobile-experience@1.0.4 # Packages for a great mobile UX +mongo@1.2.0 # The database Meteor supports right now +reactive-var@1.0.11 # Reactive variable for tracker +tracker@1.1.3 # Meteor's client-side reactive programming library + +standard-minifier-css@1.3.4 # CSS minifier run for production mode +standard-minifier-js@2.1.1 # JS minifier run for production mode +es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. +ecmascript@0.8.2 # Enable ECMAScript2015+ syntax in app code +shell-server@0.2.4 # Server-side component of the `meteor shell` command + +react-meteor-data +alanning:roles +fourseven:scss +twbs:bootstrap +accounts-base@1.3.2 +accounts-password@1.4.0 +service-configuration@1.0.11 +accounts-facebook@1.2.1 +accounts-github@1.3.0 +accounts-google@1.2.0 +themeteorchef:bert +fortawesome:fontawesome +aldeed:collection2-core@2.0.1 +audit-argument-checks@1.0.7 +ddp-rate-limiter@1.0.7 +dynamic-import@0.1.1 +static-html diff --git a/.meteor/platforms b/.meteor/platforms new file mode 100644 index 0000000..efeba1b --- /dev/null +++ b/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/.meteor/release b/.meteor/release new file mode 100644 index 0000000..47c31ab --- /dev/null +++ b/.meteor/release @@ -0,0 +1 @@ +METEOR@1.5.2 diff --git a/.meteor/versions b/.meteor/versions new file mode 100644 index 0000000..87e5bde --- /dev/null +++ b/.meteor/versions @@ -0,0 +1,104 @@ +accounts-base@1.3.3 +accounts-facebook@1.2.1 +accounts-github@1.3.0 +accounts-google@1.2.0 +accounts-oauth@1.1.15 +accounts-password@1.4.0 +alanning:roles@1.2.16 +aldeed:collection2-core@2.0.1 +allow-deny@1.0.9 +audit-argument-checks@1.0.7 +autoupdate@1.3.12 +babel-compiler@6.20.0 +babel-runtime@1.0.1 +base64@1.0.10 +binary-heap@1.0.10 +blaze@2.3.2 +blaze-tools@1.0.10 +boilerplate-generator@1.2.0 +caching-compiler@1.1.9 +caching-html-compiler@1.1.2 +callback-hook@1.0.10 +check@1.2.5 +ddp@1.3.1 +ddp-client@2.1.3 +ddp-common@1.2.9 +ddp-rate-limiter@1.0.7 +ddp-server@2.0.2 +deps@1.0.12 +diff-sequence@1.0.7 +dynamic-import@0.1.3 +ecmascript@0.8.2 +ecmascript-runtime@0.4.1 +ecmascript-runtime-client@0.4.3 +ecmascript-runtime-server@0.4.1 +ejson@1.0.14 +email@1.2.3 +es5-shim@4.6.15 +facebook-oauth@1.3.2 +fastclick@1.0.13 +fortawesome:fontawesome@4.7.0 +fourseven:scss@4.5.4 +geojson-utils@1.0.10 +github-oauth@1.2.0 +google-oauth@1.2.4 +hot-code-push@1.0.4 +html-tools@1.0.11 +htmljs@1.0.11 +http@1.2.12 +id-map@1.0.9 +jquery@1.11.10 +launch-screen@1.1.1 +livedata@1.0.18 +localstorage@1.1.1 +logging@1.1.17 +meteor@1.7.2 +meteor-base@1.1.0 +minifier-css@1.2.16 +minifier-js@2.1.3 +minimongo@1.3.1 +mobile-experience@1.0.4 +mobile-status-bar@1.0.14 +modules@0.10.0 +modules-runtime@0.8.0 +mongo@1.2.2 +mongo-dev-server@1.0.1 +mongo-id@1.0.6 +npm-bcrypt@0.9.3 +npm-mongo@2.2.30 +oauth@1.1.13 +oauth2@1.1.11 +observe-sequence@1.0.16 +ordered-dict@1.0.9 +promise@0.9.0 +raix:eventemitter@0.1.3 +random@1.0.10 +rate-limit@1.0.8 +react-meteor-data@0.2.13 +reactive-dict@1.1.9 +reactive-var@1.0.11 +reload@1.1.11 +retry@1.0.9 +routepolicy@1.0.12 +service-configuration@1.0.11 +session@1.1.7 +sha@1.0.9 +shell-server@0.2.4 +spacebars@1.0.15 +spacebars-compiler@1.1.3 +srp@1.0.10 +standard-minifier-css@1.3.5 +standard-minifier-js@2.1.1 +static-html@1.2.2 +templating@1.3.2 +templating-compiler@1.3.3 +templating-runtime@1.3.2 +templating-tools@1.1.2 +themeteorchef:bert@2.1.3 +tmeasday:check-npm-versions@0.3.1 +tracker@1.1.3 +twbs:bootstrap@3.3.6 +underscore@1.0.10 +url@1.1.0 +webapp@1.3.19 +webapp-hashing@1.0.9 diff --git a/client/main.html b/client/main.html new file mode 100644 index 0000000..db2b85f --- /dev/null +++ b/client/main.html @@ -0,0 +1,13 @@ + + + Pup + + + + + + + + +
+ diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..1412f30 --- /dev/null +++ b/client/main.js @@ -0,0 +1 @@ +import '../imports/startup/client'; diff --git a/imports/api/Documents/Documents.js b/imports/api/Documents/Documents.js new file mode 100644 index 0000000..4974aab --- /dev/null +++ b/imports/api/Documents/Documents.js @@ -0,0 +1,51 @@ +/* eslint-disable consistent-return */ + +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; + +const Documents = new Mongo.Collection('Documents'); + +Documents.allow({ + insert: () => false, + update: () => false, + remove: () => false, +}); + +Documents.deny({ + insert: () => true, + update: () => true, + remove: () => true, +}); + +Documents.schema = new SimpleSchema({ + owner: { + type: String, + label: 'The ID of the user this document belongs to.', + }, + createdAt: { + type: String, + label: 'The date this document was created.', + autoValue() { + if (this.isInsert) return (new Date()).toISOString(); + }, + }, + updatedAt: { + type: String, + label: 'The date this document was last updated.', + autoValue() { + if (this.isInsert || this.isUpdate) return (new Date()).toISOString(); + }, + }, + title: { + type: String, + label: 'The title of the document.', + }, + body: { + type: String, + label: 'The body of the document.', + }, +}); + +Documents.attachSchema(Documents.schema); + +export default Documents; diff --git a/imports/api/Documents/methods.js b/imports/api/Documents/methods.js new file mode 100644 index 0000000..29fbd8b --- /dev/null +++ b/imports/api/Documents/methods.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Documents from './Documents'; +import rateLimit from '../../modules/rate-limit'; + +Meteor.methods({ + 'documents.insert': function documentsInsert(doc) { + check(doc, { + title: String, + body: String, + }); + + try { + return Documents.insert({ owner: this.userId, ...doc }); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, + 'documents.update': function documentsUpdate(doc) { + check(doc, { + _id: String, + title: String, + body: String, + }); + + try { + const documentId = doc._id; + Documents.update(documentId, { $set: doc }); + return documentId; // Return _id so we can redirect to document after update. + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, + 'documents.remove': function documentsRemove(documentId) { + check(documentId, String); + + try { + return Documents.remove(documentId); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, +}); + +rateLimit({ + methods: [ + 'documents.insert', + 'documents.update', + 'documents.remove', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Documents/server/publications.js b/imports/api/Documents/server/publications.js new file mode 100644 index 0000000..f667a8b --- /dev/null +++ b/imports/api/Documents/server/publications.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Documents from '../Documents'; + +Meteor.publish('documents', function documents() { + return Documents.find({ owner: this.userId }); +}); + +// Note: documents.view is also used when editing an existing document. +Meteor.publish('documents.view', function documentsView(documentId) { + check(documentId, String); + return Documents.find({ _id: documentId, owner: this.userId }); +}); diff --git a/imports/api/Invoices/Invoices.js b/imports/api/Invoices/Invoices.js new file mode 100644 index 0000000..f7f70c0 --- /dev/null +++ b/imports/api/Invoices/Invoices.js @@ -0,0 +1,131 @@ +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; + +const Invoices = new Mongo.Collection('Invoices'); + +Invoices.allow({ + insert: () => false, + update: () => false, + remove: () => false, +}); + +Invoices.deny({ + insert: () => true, + update: () => true, + remove: () => true, +}); + +const InvoicesSchema = new SimpleSchema({ + createdAt: { + type: String, + label: 'The date this invoice was created.', + autoValue() { + if (this.isInsert) return (new Date()).toISOString(); + return this.value; + }, + }, + sentAt: { + type: String, + label: 'The date this invoice was sent.', + optional: true, + }, + paidAt: { + type: String, + label: 'The date this invoice was paid at.', + optional: true, + }, + status: { + type: String, + allowedValues: ['draft', 'sent', 'paid', 'overdue'], + label: 'The current status of this invoice.', + }, + owner: { + type: String, + label: 'The userId that owns this invoice', + }, + number: { + type: String, + label: 'the number of this invoice.', + autoValue() { + if (this.isInsert) return (Invoices.find({ owner: this.userId }).count() + 1).toString(); + return this.value; + }, + }, + recipientId: { + type: String, + label: 'The ID of the recipient in the recipients collection.', + }, + recipient: { + type: Object, + label: 'The invoice recipient\'s details at the time of sending.', + optional: true, + }, + 'recipient.name': { + type: String, + label: 'The recipient\'s name.', + }, + 'recipient.mailingAddress': { + type: String, + label: 'The recipient\'s mailing address.', + }, + 'recipient.contact': { + type: Object, + label: 'The contact the invoice was sent to.', + }, + 'recipient.contact.name': { + type: String, + label: 'The full name of the contact.', + }, + 'recipient.contact.emailAddress': { + type: String, + label: 'The email address of the contact.', + }, + due: { + type: String, + label: 'The date this invoice is due.', + }, + subject: { + type: String, + label: 'What is this invoice for?', + }, + lineItems: { + type: Array, + label: 'The line items for this invoice.', + defaultValue: [], + }, + 'lineItems.$': { + type: Object, + label: 'A line item for this invoice.', + }, + 'lineItems.$._id': { + type: String, + label: 'The unique ID for this line item.', + }, + 'lineItems.$.description': { + type: String, + label: 'The description for this line item.', + }, + 'lineItems.$.quantity': { + type: Number, + label: 'The quantity for this line item.', + }, + 'lineItems.$.amount': { + type: Number, + label: 'The amount for this line item in cents.', + }, + total: { + type: Number, + label: 'The total price of the invoice when it was sent in cents.', + optional: true, + }, + notes: { + type: String, + label: 'Notes about this invoice.', + optional: true, + }, +}); + +Invoices.attachSchema(InvoicesSchema); + +export default Invoices; + diff --git a/imports/api/Invoices/methods.js b/imports/api/Invoices/methods.js new file mode 100644 index 0000000..2783219 --- /dev/null +++ b/imports/api/Invoices/methods.js @@ -0,0 +1,134 @@ +/* eslint-disable max-len */ + +import Stripe from 'stripe'; +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Invoices from './Invoices'; +import Recipients from '../Recipients/Recipients'; +import sendEmail from '../../modules/server/send-email'; +import rateLimit from '../../modules/rate-limit'; +import { centsToDollars, formatAsCurrency } from '../../modules/currency-conversions'; +import generateInvoiceAsPDF from '../../modules/server/generate-invoice-as-pdf'; + +const stripe = Stripe(Meteor.settings.private.stripe); + +const totalLineItems = lineItems => + lineItems.reduce((sum, { amount, quantity }) => { + const itemTotalAmount = amount * quantity; + return sum + itemTotalAmount; + }, 0); + +const handleSendInvoice = (invoiceId) => { + try { + const invoice = Invoices.findOne(invoiceId); + const owner = Meteor.users.findOne({ _id: invoice.owner }, { fields: { profile: 1, emails: 1 } }); + const ownerName = `${owner.profile.name.first} ${owner.profile.name.last}`; + const recipient = Recipients.findOne(invoice.recipientId, { fields: { contacts: 1 } }); + const invoiceTotal = formatAsCurrency(centsToDollars(invoice.total)); + + generateInvoiceAsPDF({ invoiceId }) + .then(Meteor.bindEnvironment((pdfAsBase64) => { + if (invoice.status === 'paid') { + // Sneakily add the invoice owner to the recipient's contacts list so they receive a confirmation, too. + recipient.contacts.push({ + firstName: owner.profile.name.first, + lastName: owner.profile.name.last, + emailAddress: owner.emails[0].address, + }); + } + + recipient.contacts.forEach(({ firstName, lastName, emailAddress }) => { + const subject = invoice.status === 'sent' ? + `[CloudControl] ${ownerName} has sent you an invoice for ${invoiceTotal}` : + `[CloudControl] Payment confirmation for Invoice #${invoice.number}: ${invoice.subject}`; + + sendEmail({ + from: 'CloudControl ', + to: `${firstName} ${lastName} <${emailAddress}>`, + subject, + template: invoice.status === 'sent' ? 'invoice' : 'invoice-paid', + templateVars: { + invoiceNumber: invoice.number, + firstName, + senderName: ownerName, + invoiceTotal, + invoiceUrl: Meteor.absoluteUrl(`invoices/${invoiceId}/pay`), + }, + attachments: [{ + filename: `cloudcontrol_invoice${invoiceId}.pdf`, + content: pdfAsBase64, + encoding: 'base64', + }], + }); + }); + })) + .catch((error) => { + throw new Meteor.Error('500', error); + }); + } catch (exception) { + console.warn(exception); + } +}; + +Meteor.methods({ + 'invoices.insert': function invoicesInsert(invoice) { + check(invoice, Object); + return Invoices.insert({ + ...invoice, + status: 'draft', + owner: this.userId, + total: totalLineItems(invoice.lineItems), + }); + }, + 'invoices.update': function invoicesUpdate(invoice) { + check(invoice, Object); + const invoiceId = invoice._id; + const isOwner = Invoices.findOne({ _id: invoiceId, owner: this.userId }); + + if (isOwner) { + Invoices.update(invoiceId, { $set: { + ...invoice, + total: totalLineItems(invoice.lineItems), + } }); + + if (invoice.isSending) { + // Update here so the correct status (sent) is shown on the PDF. + Invoices.update(invoiceId, { $set: { status: 'sent' } }); + handleSendInvoice(invoiceId); + } + + return invoiceId; + } + + throw new Meteor.Error('500', 'Sorry, you\'re not allowed to update this!'); + }, + 'invoices.send': function invoicesSend(invoiceId) { + check(invoiceId, String); + handleSendInvoice(invoiceId); + }, + 'invoices.pay': function invoicesPay(options) { + check(options, Object); + const { invoiceId, source } = options; + const invoice = Invoices.findOne(invoiceId); + stripe.charges.create({ + amount: invoice.total, + currency: 'usd', + description: `Payment for Invoice #${invoice.number}: ${invoice.subject}`, + source, + }, Meteor.bindEnvironment(() => { + Invoices.update(invoiceId, { $set: { status: 'paid' } }); + handleSendInvoice(invoiceId); + })); + }, +}); + +rateLimit({ + methods: [ + 'invoices.insert', + 'invoices.update', + 'invoices.send', + 'invoices.pay', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Invoices/server/publications.js b/imports/api/Invoices/server/publications.js new file mode 100644 index 0000000..ee8b98e --- /dev/null +++ b/imports/api/Invoices/server/publications.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Invoices from '../Invoices'; +import Recipients from '../../Recipients/Recipients'; + +Meteor.publish('invoices', function invoices() { + return Invoices.find({ owner: this.userId }, { sort: { createdAt: -1 } }); +}); + +Meteor.publish('invoices.view', function invoicesView(invoiceId) { + check(invoiceId, String); + const invoice = Invoices.findOne({ _id: invoiceId }); + return invoice.status !== 'draft' ? [ + Invoices.find({ _id: invoiceId }), + Recipients.find({ _id: invoice.recipientId }), + ] : + Invoices.find({ _id: invoiceId, owner: this.userId }); +}); diff --git a/imports/api/OAuth/server/methods.js b/imports/api/OAuth/server/methods.js new file mode 100644 index 0000000..6696da4 --- /dev/null +++ b/imports/api/OAuth/server/methods.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import rateLimit from '../../../modules/rate-limit'; + +Meteor.methods({ + 'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) { + check(services, Array); + + try { + const verifiedServices = []; + services.forEach((service) => { + if (ServiceConfiguration.configurations.findOne({ service })) { + verifiedServices.push(service); + } + }); + return verifiedServices.sort(); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, +}); + +rateLimit({ + methods: [ + 'oauth.verifyConfiguration', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Recipients/Recipients.js b/imports/api/Recipients/Recipients.js new file mode 100644 index 0000000..8685c72 --- /dev/null +++ b/imports/api/Recipients/Recipients.js @@ -0,0 +1,66 @@ +import { Mongo } from 'meteor/mongo'; +import { Random } from 'meteor/random'; +import SimpleSchema from 'simpl-schema'; + +const Recipients = new Mongo.Collection('Recipients'); + +Recipients.allow({ + insert: () => false, + update: () => false, + remove: () => false, +}); + +Recipients.deny({ + insert: () => true, + update: () => true, + remove: () => true, +}); + +const RecipientsSchema = new SimpleSchema({ + owner: { + type: String, + label: 'The userId that owns this recipient.', // Spooky! + }, + name: { + type: String, + label: 'The name of this recipient.', + }, + mailingAddress: { + type: String, + label: 'The mailing address of this recipient.', + optional: true, + }, + contacts: { + type: Array, + label: 'The contacts for this recipient.', + min: 1, + }, + 'contacts.$': { + type: Object, + label: 'A contact for this recipient.', + }, + 'contacts.$._id': { + type: String, + label: 'The unique ID for this contact.', + autoValue() { + if (this.isInsert) return Random.id(); + return this.value; + }, + }, + 'contacts.$.firstName': { + type: String, + label: 'The first name of the contact.', + }, + 'contacts.$.lastName': { + type: String, + label: 'The first name of the contact.', + }, + 'contacts.$.emailAddress': { + type: String, + label: 'The email address of the contact.', + }, +}); + +Recipients.attachSchema(RecipientsSchema); + +export default Recipients; diff --git a/imports/api/Recipients/methods.js b/imports/api/Recipients/methods.js new file mode 100644 index 0000000..63c6a34 --- /dev/null +++ b/imports/api/Recipients/methods.js @@ -0,0 +1,28 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Recipients from './Recipients'; + +Meteor.methods({ + 'recipients.fetch': function recipientsFetch() { + return Recipients.find({ owner: this.userId }).fetch(); + }, + 'recipients.insert': function recipientsInsert(recipient) { + check(recipient, Object); + return Recipients.insert({ + ...recipient, + owner: this.userId, + }); + }, + 'recipients.update': function recipientsUpdate(recipient) { + check(recipient, Object); + const recipientId = recipient._id; + const isOwner = Recipients.findOne({ _id: recipientId, owner: this.userId }); + + if (isOwner) { + Recipients.update(recipientId, { $set: recipient }); + return recipientId; + } + + throw new Meteor.Error('500', 'Sorry, you\'re not allowed to updated this!'); + }, +}); diff --git a/imports/api/Recipients/server/publications.js b/imports/api/Recipients/server/publications.js new file mode 100644 index 0000000..7b83097 --- /dev/null +++ b/imports/api/Recipients/server/publications.js @@ -0,0 +1,12 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Recipients from '../Recipients'; + +Meteor.publish('recipients', function recipients() { + return Recipients.find({ owner: this.userId }, { sort: { name: 1 } }); +}); + +Meteor.publish('recipients.view', function recipientsView(recipientId) { + check(recipientId, String); + return Recipients.find({ _id: recipientId, owner: this.userId }); +}); diff --git a/imports/api/Users/server/edit-profile.js b/imports/api/Users/server/edit-profile.js new file mode 100644 index 0000000..fc44093 --- /dev/null +++ b/imports/api/Users/server/edit-profile.js @@ -0,0 +1,32 @@ +/* eslint-disable consistent-return */ + +import { Meteor } from 'meteor/meteor'; + +let action; + +const updateUser = (userId, { emailAddress, profile }) => { + try { + Meteor.users.update(userId, { + $set: { + 'emails.0.address': emailAddress, + profile, + }, + }); + } catch (exception) { + action.reject(`[editProfile.updateUser] ${exception}`); + } +}; + +const editProfile = ({ userId, profile }, promise) => { + try { + action = promise; + updateUser(userId, profile); + action.resolve(); + } catch (exception) { + action.reject(`[editProfile.handler] ${exception}`); + } +}; + +export default options => +new Promise((resolve, reject) => +editProfile(options, { resolve, reject })); diff --git a/imports/api/Users/server/methods.js b/imports/api/Users/server/methods.js new file mode 100644 index 0000000..130c6ee --- /dev/null +++ b/imports/api/Users/server/methods.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Accounts } from 'meteor/accounts-base'; +import editProfile from './edit-profile'; +import rateLimit from '../../../modules/rate-limit'; + +Meteor.methods({ + 'users.sendVerificationEmail': function usersSendVerificationEmail() { + return Accounts.sendVerificationEmail(this.userId); + }, + 'users.editProfile': function usersEditProfile(profile) { + check(profile, { + emailAddress: String, + profile: { + name: { + first: String, + last: String, + }, + }, + }); + + return editProfile({ userId: this.userId, profile }) + .then(response => response) + .catch((exception) => { + throw new Meteor.Error('500', exception); + }); + }, +}); + +rateLimit({ + methods: [ + 'users.sendVerificationEmail', + 'users.editProfile', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Users/server/publications.js b/imports/api/Users/server/publications.js new file mode 100644 index 0000000..7728b4b --- /dev/null +++ b/imports/api/Users/server/publications.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; + +Meteor.publish('users.editProfile', function usersProfile() { + return Meteor.users.find(this.userId, { + fields: { + emails: 1, + profile: 1, + services: 1, + }, + }); +}); diff --git a/imports/api/Users/server/send-welcome-email.js b/imports/api/Users/server/send-welcome-email.js new file mode 100644 index 0000000..d15298d --- /dev/null +++ b/imports/api/Users/server/send-welcome-email.js @@ -0,0 +1,25 @@ +import sendEmail from '../../../modules/server/send-email'; +import getOAuthProfile from '../../../modules/get-oauth-profile'; + +export default (options, user) => { + const OAuthProfile = getOAuthProfile(options, user); + + const applicationName = 'Application Name'; + const firstName = OAuthProfile ? OAuthProfile.name.first : options.profile.name.first; + const emailAddress = OAuthProfile ? OAuthProfile.email : options.email; + + return sendEmail({ + to: emailAddress, + from: `${applicationName} `, + subject: `[${applicationName}] Welcome, ${firstName}!`, + template: 'welcome', + templateVars: { + applicationName, + firstName, + welcomeUrl: Meteor.absoluteUrl('documents'), // e.g., returns http://localhost:3000/documents + }, + }) + .catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); +}; diff --git a/imports/api/Utility/server/methods.js b/imports/api/Utility/server/methods.js new file mode 100644 index 0000000..498caae --- /dev/null +++ b/imports/api/Utility/server/methods.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import getPrivateFile from '../../../modules/server/get-private-file'; +import parseMarkdown from '../../../modules/parse-markdown'; + +Meteor.methods({ + 'utility.getPage': function utilityGetPage(fileName) { + check(fileName, String); + return parseMarkdown(getPrivateFile(`pages/${fileName}.md`)); + }, +}); diff --git a/imports/modules/currency-conversions.js b/imports/modules/currency-conversions.js new file mode 100644 index 0000000..8008bb5 --- /dev/null +++ b/imports/modules/currency-conversions.js @@ -0,0 +1,13 @@ +import { format } from 'accounting-js'; + +export const formatAsCurrency = format; + +export const currencyToFloat = value => (value ? parseFloat(value.replace(/[^0-9-.]/g, '')) : 0); + +export const calculateAndFormatTotal = (quantity, amount, options) => { + // We assume that amount will be a formatted currency string like $10,999.52. + const total = (quantity * amount).toFixed(2); + return format(total, options); +}; + +export const centsToDollars = cents => (cents / 100).toFixed(2); diff --git a/imports/modules/get-oauth-profile.js b/imports/modules/get-oauth-profile.js new file mode 100644 index 0000000..91866dc --- /dev/null +++ b/imports/modules/get-oauth-profile.js @@ -0,0 +1,42 @@ +const parseGoogleData = service => { + return { + email: service.email, + name: { + first: service.given_name, + last: service.family_name, + }, + }; +}; + +const parseGithubData = (profile, service) => { + const name = profile.name.split(' '); + return { + email: service.email, + name: { + first: name[0], + last: name[1], + }, + }; +}; + +const parseFacebookData = service => { + return { + email: service.email, + name: { + first: service.first_name, + last: service.last_name, + }, + }; +}; + +const getDataForService = (profile, services) => { + if (services.facebook) return parseFacebookData(services.facebook); + if (services.github) return parseGithubData(profile, services.github); + if (services.google) return parseGoogleData(services.google); +}; + +export default (options, user) => { + const isOAuth = !options.password; + const serviceData = isOAuth ? getDataForService(options.profile, user.services) : null; + return isOAuth ? serviceData : null; +}; diff --git a/imports/modules/parse-markdown.js b/imports/modules/parse-markdown.js new file mode 100644 index 0000000..9ccaffd --- /dev/null +++ b/imports/modules/parse-markdown.js @@ -0,0 +1,8 @@ +import { Parser, HtmlRenderer } from 'commonmark'; + +export default (markdown, options) => { + const reader = new Parser(); + const writer = options ? new HtmlRenderer(options) : new HtmlRenderer(); + const parsed = reader.parse(markdown); + return writer.render(parsed); +}; diff --git a/imports/modules/rate-limit.js b/imports/modules/rate-limit.js new file mode 100644 index 0000000..d98096f --- /dev/null +++ b/imports/modules/rate-limit.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +export default ({ methods, limit, timeRange }) => { + if (Meteor.isServer) { + DDPRateLimiter.addRule({ + name(name) { return methods.indexOf(name) > -1; }, + connectionId() { return true; }, + }, limit, timeRange); + } +}; diff --git a/imports/modules/server/generate-invoice-as-pdf.js b/imports/modules/server/generate-invoice-as-pdf.js new file mode 100644 index 0000000..7615ea3 --- /dev/null +++ b/imports/modules/server/generate-invoice-as-pdf.js @@ -0,0 +1,66 @@ +import ReactDOMServer from 'react-dom/server'; +import pdf from 'html-pdf'; +import fs from 'fs'; +import StaticInvoice from '../../ui/components/StaticInvoice/StaticInvoice'; +import Invoices from '../../api/Invoices/Invoices'; +import Recipients from '../../api/Recipients/Recipients'; + +let action; + +const getBase64String = (path) => { + try { + const file = fs.readFileSync(path); + return new Buffer(file).toString('base64'); + } catch (exception) { + action.reject(exception); + } +}; + +const generatePDF = (html, fileName, format) => { + try { + pdf.create(html, { + format: 'letter', + border: { top: '0.4in', right: '0.6in', bottom: '0.6in', left: '0.6in' }, + }).toFile(`./tmp/${fileName}`, (error, response) => { + if (error) action.reject(error); + if (response) { + action.resolve(getBase64String(response.filename)); + fs.unlink(response.filename); + } + }); + } catch (exception) { + action.reject(exception); + } +}; + +const getInvoiceAsHTML = ({ invoice, recipient }) => { + try { + return ReactDOMServer.renderToStaticMarkup( + StaticInvoice({ loading: false, invoice, recipient }), + ); + } catch (exception) { + action.reject(exception); + } +}; + +const getInvoiceData = (invoiceId) => { + try { + const invoice = Invoices.findOne(invoiceId); + const recipient = Recipients.findOne(invoice.recipientId); + return { invoice, recipient }; + } catch (exception) { + action.reject(exception); + } +}; + +const handler = ({ invoiceId }, promise) => { + action = promise; + const invoiceData = getInvoiceData(invoiceId); + const html = getInvoiceAsHTML(invoiceData); + const fileName = `cloudcontrol_invoice_${invoiceId}.pdf`; + if (html && fileName) generatePDF(html, fileName); +}; + +export default options => + new Promise((resolve, reject) => + handler(options, { resolve, reject })); diff --git a/imports/modules/server/get-private-file.js b/imports/modules/server/get-private-file.js new file mode 100644 index 0000000..d2204d8 --- /dev/null +++ b/imports/modules/server/get-private-file.js @@ -0,0 +1,3 @@ +import fs from 'fs'; + +export default path => fs.readFileSync(`assets/app/${path}`, 'utf8'); diff --git a/imports/modules/server/handlebars-email-to-html.js b/imports/modules/server/handlebars-email-to-html.js new file mode 100644 index 0000000..4d7e809 --- /dev/null +++ b/imports/modules/server/handlebars-email-to-html.js @@ -0,0 +1,11 @@ +import handlebars from 'handlebars'; +import juice from 'juice'; + +export default (handlebarsMarkup, context, options) => { + if (handlebarsMarkup && context) { + const template = handlebars.compile(handlebarsMarkup); + return options && !options.inlineCss ? template(context) : juice(template(context)); // Use juice to inline CSS styles from unless disabled. + } + + throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.'); +}; diff --git a/imports/modules/server/handlebars-email-to-text.js b/imports/modules/server/handlebars-email-to-text.js new file mode 100644 index 0000000..c348901 --- /dev/null +++ b/imports/modules/server/handlebars-email-to-text.js @@ -0,0 +1,10 @@ +import handlebars from 'handlebars'; + +export default (handlebarsMarkup, context) => { + if (handlebarsMarkup && context) { + const template = handlebars.compile(handlebarsMarkup); + return template(context); + } + + throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.'); +}; diff --git a/imports/modules/server/send-email.js b/imports/modules/server/send-email.js new file mode 100644 index 0000000..08b731d --- /dev/null +++ b/imports/modules/server/send-email.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Email } from 'meteor/email'; +import getPrivateFile from './get-private-file'; +import templateToText from './handlebars-email-to-text'; +import templateToHTML from './handlebars-email-to-html'; + +const sendEmail = (options, { resolve, reject }) => { + try { + Meteor.defer(() => { + Email.send(options); + resolve(); + }); + } catch (exception) { + reject(exception); + } +}; + +export default ({ text, html, template, templateVars, ...rest }) => { + if (text || html || template) { + return new Promise((resolve, reject) => { + sendEmail({ + ...rest, + text: template ? templateToText(getPrivateFile(`email-templates/${template}.txt`), (templateVars || {})) : text, + html: template ? templateToHTML(getPrivateFile(`email-templates/${template}.html`), (templateVars || {})) : html, + }, { resolve, reject }); + }); + } + throw new Error('Please pass an HTML string, text, or template name to compile for your message\'s body.'); +}; diff --git a/imports/modules/stripe-checkout.js b/imports/modules/stripe-checkout.js new file mode 100644 index 0000000..c362d45 --- /dev/null +++ b/imports/modules/stripe-checkout.js @@ -0,0 +1,13 @@ +export default (callback) => { + const existingScript = document.getElementById('stripe-checkout'); + + if (!existingScript) { + const script = document.createElement('script'); + script.src = 'https://checkout.stripe.com/checkout.js'; + script.id = 'stripe-checkout'; + document.body.appendChild(script); + script.onload = callback; // This should contain the StripeCheckout.configure() method. + } else { + callback(); + } +}; diff --git a/imports/modules/validate.js b/imports/modules/validate.js new file mode 100644 index 0000000..33f6175 --- /dev/null +++ b/imports/modules/validate.js @@ -0,0 +1,4 @@ +import $ from 'jquery'; +import 'jquery-validation'; + +export default (form, options) => $(form).validate(options); diff --git a/imports/startup/client/index.js b/imports/startup/client/index.js new file mode 100644 index 0000000..ccebfda --- /dev/null +++ b/imports/startup/client/index.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { Meteor } from 'meteor/meteor'; +import App from '../../ui/layouts/App/App'; + +import '../../ui/stylesheets/app.scss'; + +Meteor.startup(() => render(, document.getElementById('react-root'))); diff --git a/imports/startup/server/accounts/email-templates.js b/imports/startup/server/accounts/email-templates.js new file mode 100644 index 0000000..40a2f18 --- /dev/null +++ b/imports/startup/server/accounts/email-templates.js @@ -0,0 +1,59 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import getPrivateFile from '../../../modules/server/get-private-file'; +import templateToHTML from '../../../modules/server/handlebars-email-to-html'; +import templateToText from '../../../modules/server/handlebars-email-to-text'; + +const name = 'Application Name'; +const email = ''; +const from = `${name} ${email}`; +const emailTemplates = Accounts.emailTemplates; + +emailTemplates.siteName = name; +emailTemplates.from = from; + +emailTemplates.verifyEmail = { + subject() { + return `[${name}] Verify Your Email Address`; + }, + html(user, url) { + return templateToHTML(getPrivateFile('email-templates/verify-email.html'), { + applicationName: name, + firstName: user.profile.name.first, + verifyUrl: url.replace('#/', '') + }); + }, + text(user, url) { + const urlWithoutHash = url.replace('#/', ''); + if (Meteor.isDevelopment) console.info(`Verify Email Link: ${urlWithoutHash}`); // eslint-disable-line + return templateToText(getPrivateFile('email-templates/verify-email.txt'), { + applicationName: name, + firstName: user.profile.name.first, + verifyUrl: urlWithoutHash, + }); + }, +}; + +emailTemplates.resetPassword = { + subject() { + return `[${name}] Reset Your Password`; + }, + html(user, url) { + return templateToHTML(getPrivateFile('email-templates/reset-password.html'), { + firstName: user.profile.name.first, + applicationName: name, + emailAddress: user.emails[0].address, + resetUrl: url.replace('#/', ''), + }); + }, + text(user, url) { + const urlWithoutHash = url.replace('#/', ''); + if (Meteor.isDevelopment) console.info(`Reset Password Link: ${urlWithoutHash}`); // eslint-disable-line + return templateToText(getPrivateFile('email-templates/reset-password.txt'), { + firstName: user.profile.name.first, + applicationName: name, + emailAddress: user.emails[0].address, + resetUrl: urlWithoutHash, + }); + }, +}; diff --git a/imports/startup/server/accounts/index.js b/imports/startup/server/accounts/index.js new file mode 100644 index 0000000..f9400a8 --- /dev/null +++ b/imports/startup/server/accounts/index.js @@ -0,0 +1,3 @@ +import './oauth'; +import './email-templates'; +import './on-create-user.js'; diff --git a/imports/startup/server/accounts/oauth.js b/imports/startup/server/accounts/oauth.js new file mode 100644 index 0000000..545519a --- /dev/null +++ b/imports/startup/server/accounts/oauth.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +const OAuthSettings = Meteor.settings.private.OAuth; + +if (OAuthSettings) { + Object.keys(OAuthSettings).forEach((service) => { + ServiceConfiguration.configurations.upsert( + { service }, + { $set: OAuthSettings[service] }, + ); + }); +} diff --git a/imports/startup/server/accounts/on-create-user.js b/imports/startup/server/accounts/on-create-user.js new file mode 100644 index 0000000..92d8b40 --- /dev/null +++ b/imports/startup/server/accounts/on-create-user.js @@ -0,0 +1,9 @@ +import { Accounts } from 'meteor/accounts-base'; +import sendWelcomeEmail from '../../../api/Users/server/send-welcome-email'; + +Accounts.onCreateUser((options, user) => { + const userToCreate = user; + if (options.profile) userToCreate.profile = options.profile; + sendWelcomeEmail(options, user); + return userToCreate; +}); diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js new file mode 100644 index 0000000..edd05c3 --- /dev/null +++ b/imports/startup/server/api.js @@ -0,0 +1,16 @@ + +import '../../api/Documents/methods'; +import '../../api/Documents/server/publications'; + +import '../../api/Invoices/methods'; +import '../../api/Invoices/server/publications'; + +import '../../api/OAuth/server/methods'; + +import '../../api/Recipients/methods'; +import '../../api/Recipients/server/publications'; + +import '../../api/Users/server/methods'; +import '../../api/Users/server/publications'; + +import '../../api/Utility/server/methods'; diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js new file mode 100644 index 0000000..377599c --- /dev/null +++ b/imports/startup/server/email.js @@ -0,0 +1,3 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isDevelopment) process.env.MAIL_URL = Meteor.settings.private.MAIL_URL; diff --git a/imports/startup/server/fixtures.js b/imports/startup/server/fixtures.js new file mode 100644 index 0000000..0c6a41f --- /dev/null +++ b/imports/startup/server/fixtures.js @@ -0,0 +1,61 @@ +import seeder from '@cleverbeagle/seeder'; +import { Meteor } from 'meteor/meteor'; +import { Random } from 'meteor/random'; +import Recipients from '../../api/Recipients/Recipients'; + +const recipientsSeed = (userId, faker) => ({ + collection: Recipients, + environments: ['development', 'staging'], + noLimit: true, + modelCount: 5, + model() { + return { + owner: userId, + name: faker.company.companyName(), + mailingAddress: `${faker.address.streetAddress()}\n${faker.address.city()}, ${faker.address.state()} ${faker.address.zipCode()}`, + contacts: [{ + _id: Random.id(), + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + emailAddress: faker.internet.email(), + }], + }; + }, +}); + +seeder(Meteor.users, { + environments: ['development', 'staging'], + noLimit: true, + data: [{ + email: 'admin@admin.com', + password: 'password', + profile: { + name: { + first: 'Andy', + last: 'Warhol', + }, + }, + roles: ['admin'], + data(userId, faker) { + return recipientsSeed(userId, faker); + }, + }], + modelCount: 5, + model(index, faker) { + const userCount = index + 1; + return { + email: `user+${userCount}@test.com`, + password: 'password', + profile: { + name: { + first: faker.name.firstName(), + last: faker.name.lastName(), + }, + }, + roles: ['user'], + data(userId) { + return recipientsSeed(userId, faker); + }, + }; + }, +}); diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js new file mode 100644 index 0000000..09fb6b3 --- /dev/null +++ b/imports/startup/server/index.js @@ -0,0 +1,4 @@ +import './accounts'; +import './api'; +import './fixtures'; +import './email'; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.js b/imports/ui/components/AccountPageFooter/AccountPageFooter.js new file mode 100644 index 0000000..dd5a1e7 --- /dev/null +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './AccountPageFooter.scss'; + +const AccountPageFooter = ({ children }) => ( +
+ {children} +
+); + +AccountPageFooter.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default AccountPageFooter; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss new file mode 100644 index 0000000..0e6b0df --- /dev/null +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss @@ -0,0 +1,7 @@ +@import '../../stylesheets/colors'; + +.AccountPageFooter { + margin: 25px 0 0; + padding-top: 20px; + border-top: 1px solid $gray-lighter; +} diff --git a/imports/ui/components/Authenticated/Authenticated.js b/imports/ui/components/Authenticated/Authenticated.js new file mode 100644 index 0000000..f34da2f --- /dev/null +++ b/imports/ui/components/Authenticated/Authenticated.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Route, Redirect } from 'react-router-dom'; + +const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => ( + ( + authenticated ? + (React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) : + () + )} + /> +); + +Authenticated.propTypes = { + loggingIn: PropTypes.bool.isRequired, + authenticated: PropTypes.bool.isRequired, + component: PropTypes.func.isRequired, +}; + +export default Authenticated; diff --git a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js new file mode 100644 index 0000000..c8f6b18 --- /dev/null +++ b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { LinkContainer } from 'react-router-bootstrap'; +import { Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap'; +import { Meteor } from 'meteor/meteor'; + +const AuthenticatedNavigation = ({ name, history }) => ( +
+ + +
+); + +AuthenticatedNavigation.propTypes = { + name: PropTypes.string.isRequired, +}; + +export default withRouter(AuthenticatedNavigation); diff --git a/imports/ui/components/Content/Content.js b/imports/ui/components/Content/Content.js new file mode 100644 index 0000000..b38d9ca --- /dev/null +++ b/imports/ui/components/Content/Content.js @@ -0,0 +1,16 @@ +/* eslint-disable react/no-danger */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import './Content.scss'; + +const Content = ({ content }) => ( +
+); + +Content.propTypes = { + content: PropTypes.string.isRequired, +}; + +export default Content; diff --git a/imports/ui/components/Content/Content.scss b/imports/ui/components/Content/Content.scss new file mode 100644 index 0000000..a1573ea --- /dev/null +++ b/imports/ui/components/Content/Content.scss @@ -0,0 +1,36 @@ +@import '../../stylesheets/mixins'; + +.Content { + max-width: 700px; + margin: 0 auto; + font-size: 14px; + line-height: 22px; + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 30px 0 20px; + } + + p { + margin-bottom: 20px; + } + + > *:first-child { + margin-top: 0px; + } + + > *:last-child { + margin-bottom: 0px; + } +} + +@include breakpoint(tablet) { + .Content { + font-size: 16px; + line-height: 22px; + } +} diff --git a/imports/ui/components/DateTimePicker/DateTimePicker.js b/imports/ui/components/DateTimePicker/DateTimePicker.js new file mode 100644 index 0000000..be83e75 --- /dev/null +++ b/imports/ui/components/DateTimePicker/DateTimePicker.js @@ -0,0 +1,14 @@ +import React from 'react'; +import Datetime from 'react-datetime'; + +import 'react-datetime/css/react-datetime.css'; + +const DateTimePicker = props => ( +
+ +
+); + +DateTimePicker.propTypes = {}; + +export default DateTimePicker; diff --git a/imports/ui/components/DocumentEditor/DocumentEditor.js b/imports/ui/components/DocumentEditor/DocumentEditor.js new file mode 100644 index 0000000..4a42df7 --- /dev/null +++ b/imports/ui/components/DocumentEditor/DocumentEditor.js @@ -0,0 +1,97 @@ +/* eslint-disable max-len, no-return-assign */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, ControlLabel, Button } from 'react-bootstrap'; +import { Meteor } from 'meteor/meteor'; +import { Bert } from 'meteor/themeteorchef:bert'; +import validate from '../../../modules/validate'; + +class DocumentEditor extends React.Component { + componentDidMount() { + const component = this; + validate(component.form, { + rules: { + title: { + required: true, + }, + body: { + required: true, + }, + }, + messages: { + title: { + required: 'Need a title in here, Seuss.', + }, + body: { + required: 'This thneeds a body, please.', + }, + }, + submitHandler() { component.handleSubmit(); }, + }); + } + + handleSubmit() { + const { history } = this.props; + const existingDocument = this.props.doc && this.props.doc._id; + const methodToCall = existingDocument ? 'documents.update' : 'documents.insert'; + const doc = { + title: this.title.value.trim(), + body: this.body.value.trim(), + }; + + if (existingDocument) doc._id = existingDocument; + + Meteor.call(methodToCall, doc, (error, documentId) => { + if (error) { + Bert.alert(error.reason, 'danger'); + } else { + const confirmation = existingDocument ? 'Document updated!' : 'Document added!'; + this.form.reset(); + Bert.alert(confirmation, 'success'); + history.push(`/documents/${documentId}`); + } + }); + } + + render() { + const { doc } = this.props; + return (
(this.form = form)} onSubmit={event => event.preventDefault()}> + + Title + (this.title = title)} + defaultValue={doc && doc.title} + placeholder="Oh, The Places You'll Go!" + /> + + + Body +