├── .babelrc
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── README.md
├── cronJobs
├── .babelrc
├── .gitignore
├── handler.js
├── package.json
├── serverless.yml
├── src
│ ├── lib
│ │ └── getRootURL.js
│ └── server
├── webpack.config.js
└── yarn.lock
├── now.json
├── package.json
├── webApp
├── .babelrc
├── components
│ ├── CopyButton.js
│ ├── DescriptionEditor.js
│ ├── Header.js
│ ├── IncomeReport.js
│ ├── MenuDrop.js
│ ├── Modal.js
│ ├── Notifier.js
│ ├── SelectList.js
│ ├── SharedStyles.js
│ └── Toggle.js
├── env-config.js
├── lib
│ ├── api.js
│ ├── context.js
│ ├── getRootURL.js
│ ├── notifier.js
│ ├── withAuth.js
│ └── withLayout.js
├── package.json
├── pages
│ ├── _document.js
│ ├── checkout.js
│ ├── contact.js
│ ├── index.js
│ ├── login.js
│ ├── mentors.js
│ ├── rate.js
│ └── signup.js
├── server
│ ├── api.js
│ ├── app.js
│ ├── auth.js
│ ├── aws.js
│ ├── constants.js
│ ├── emailTemplates.js
│ ├── gmail
│ │ ├── accessRevoked.js
│ │ ├── api.js
│ │ ├── checkLabelsAndFilters.js
│ │ ├── checkSentEmails.js
│ │ ├── createFilter.js
│ │ ├── createLabels.js
│ │ ├── index.js
│ │ ├── migrate.js
│ │ ├── passportStrategy.js
│ │ └── setHistoryStartId.js
│ ├── log.js
│ ├── models
│ │ ├── BlackList.js
│ │ ├── Invitation.js
│ │ ├── Payment.js
│ │ ├── Rating.js
│ │ └── User.js
│ ├── public-api.js
│ ├── sitemap.js
│ ├── stripe.js
│ └── utils
│ │ └── slugify.js
├── static
│ └── nprogress.css
├── test
│ └── unit
│ │ └── utils
│ │ └── slugify.test.js
└── yarn.lock
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: 'babel-eslint',
3 | extends: 'airbnb',
4 | env: {
5 | browser: true
6 | },
7 | plugins: ['react', 'jsx-a11y', 'import'],
8 | rules: {
9 | 'max-len': ['error', 100],
10 | semi: ['error', 'never'],
11 | 'comma-dangle': ['error', 'never'],
12 | 'arrow-parens': ['error', 'as-needed'],
13 | 'no-mixed-operators': 'off',
14 | 'no-underscore-dangle': ['error', { allow: ['_id'] }],
15 | 'react/react-in-jsx-scope': 'off',
16 | 'react/jsx-filename-extension': [
17 | 'error',
18 | {
19 | extensions: ['.js']
20 | }
21 | ]
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.swp
3 | .meteor-spk
4 | *.sublime-workspace
5 | tmp/
6 | node_modules/
7 | npm-debug.log
8 | .vscode/
9 | .build/*
10 |
11 | .DS_Store
12 |
13 | # build output
14 | .next
15 |
16 | .vscode/
17 | node_modules/
18 |
19 | webApp/node_modules/
20 | webApp/.build/*
21 | webApp/.next
22 | webApp/.coverage
23 | webApp/.env
24 |
25 | cronJobs/node_modules/
26 | cronJobs/.env
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Tima
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Harbor
2 | Open source web app built with React/Material-UI/Next/Express/Mongoose/MongoDB.
3 |
4 | This app allows anyone with a Gmail account to receive payments for sending advice via email.
5 |
6 |
7 | ## How can I use this app?
8 |
9 | You can learn:
10 | - React/Material-UI/Next/Express/Mongoose/MongoDB boilerplate ([up-to-date boilerplate](https://github.com/builderbook/builderbook))
11 | - Google OAuth API,
12 | - Stripe connected accounts, Stripe payments, Stripe invoices API,
13 | - Gmail API
14 |
15 |
16 | ## Screenshots
17 |
18 | Mentor settings page
19 | 
20 |
21 | Mentor contact page
22 | 
23 |
24 | Customer checkout
25 | 
26 |
27 |
28 | ## Run locally
29 | - Clone the project and run `yarn` to add packages.
30 | - Before you start the app, create a `.env` file at the app's root. This file must have _at least three env variables_: `MONGO_URL_TEST`, `Google_clientID`, `Google_clientSecret`. We recommend free MongoDB at mLab.
31 |
32 | To use all features and third-party integrations (such as Stripe, Google OAuth), add values to all env variables in `.env` file:
33 | `.env` :
34 | ```
35 | MONGO_URL="XXXXXX"
36 | MONGO_URL_TEST="XXXXXX"
37 |
38 | Google_clientID="XXXXXX"
39 | Google_clientSecret="XXXXXX"
40 |
41 | Amazon_accessKeyId="XXXXXX"
42 | Amazon_secretAccessKey="XXXXXX"
43 |
44 | Stripe_Test_ClientID="ca_XXXXXX"
45 | Stripe_Live_ClientID="ca_XXXXXX"
46 | Stripe_Test_SecretKey="sk_test_XXXXXX"
47 | Stripe_Live_SecretKey="sk_live_XXXXXX"
48 | Stripe_Live_PublishableKey="pk_live_XXXXXX"
49 | Stripe_Test_PublishableKey="pk_test_XXXXXX"
50 | ```
51 |
52 | - Before you start the app, create a `env-config.js` file at the app's root. This file makes Stripe's public keys (keys that start with `pk`) available on client. Content of this file:
53 | `env-config.js` :
54 | ```
55 | const dev = process.env.NODE_ENV !== 'production';
56 |
57 | module.exports = {
58 | StripePublishableKey: dev
59 | ? 'pk_test_XXXXXX'
60 | : 'pk_live_XXXXXX',
61 | };
62 | ```
63 |
64 | - Start the app with `yarn dev`.
65 |
66 |
67 | ## Deploy
68 | Follow these steps to deploy Harbor app with Zeit's [now](https://zeit.co/now).
69 |
70 | 1. Install now: `npm install -g now`
71 |
72 | 2. Point your domain to Zeit world nameservers: [three steps](https://zeit.co/world#get-started)
73 |
74 | 3. Check the `now.json` file. If you are using `dotenv` and `.env` for env variables, no need to change `now.json`. If you make changes to the app, check up how to [configure now](https://zeit.co/docs/features/configuration).
75 |
76 | 4. Make sure you updated `ROOT_URL` in `package.json` and `lib/getRootURL.js` files.
77 |
78 | 5. Check that you have all production-level env variable in `.env`.
79 |
80 | 6. In your terminal, deploy the app by running `now`.
81 |
82 | 7. Now outputs your deployment's URL, for example: `harbor-zomcvzgtvc.now.sh`.
83 |
84 | 8. Point successful deployment to your domain, for example: `now ln harbor-zomcvzgtvc.now.sh builderbook.org`.
85 |
86 | You are done.
87 |
--------------------------------------------------------------------------------
/cronJobs/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "env",
5 | {
6 | "targets": {
7 | "node": "6.10.3"
8 | }
9 | }
10 | ]
11 | ],
12 | "plugins": ["transform-object-rest-spread"]
13 | }
14 |
--------------------------------------------------------------------------------
/cronJobs/.gitignore:
--------------------------------------------------------------------------------
1 | # package directories
2 | node_modules
3 | jspm_packages
4 |
5 | dist
6 |
7 | # Serverless directories
8 | .serverless
--------------------------------------------------------------------------------
/cronJobs/handler.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config()
2 | const mongoose = require('mongoose')
3 |
4 | const logger = require('./dist/server/log').default
5 | const checkSentEmails = require('./dist/server/gmail/checkSentEmails').default
6 |
7 | process.env.NODE_ENV = process.env.NODE_ENV2 || process.env.NODE_ENV
8 |
9 | function connectToDB() {
10 | const dev = process.env.NODE_ENV !== 'production'
11 | let MONGO_URL = dev ? process.env.MONGO_URL_TEST : process.env.MONGO_URL
12 | MONGO_URL = process.env.USE_MONGO_TEST2 ? process.env.MONGO_URL_TEST2 : MONGO_URL
13 |
14 | mongoose.Promise = global.Promise
15 | mongoose.connect(MONGO_URL, { useMongoClient: true })
16 | }
17 |
18 | module.exports.checkSentEmailsCron = (event, context, callback) => {
19 | connectToDB()
20 |
21 | logger.info('checking sent emails')
22 | checkSentEmails().then(() => {
23 | mongoose.disconnect()
24 | logger.info('** checking sent emails: DONE')
25 | callback(null, { message: 'checking sent emails: Done', event })
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/cronJobs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "harbor-cronjobs",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "build": "babel --watch src -d dist/",
6 | "deploy": "babel src -d dist/ && serverless deploy",
7 | "invoke": "USE_MONGO_TEST2=1 NODE_ENV2=dev serverless invoke local"
8 | },
9 | "devDependencies": {
10 | "babel-cli": "^6.26.0",
11 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
12 | "babel-preset-env": "^1.6.0",
13 | "webpack": "^3.5.5"
14 | },
15 | "dependencies": {
16 | "aws-sdk": "^2.102.0",
17 | "dotenv": "^4.0.0",
18 | "email-addresses": "^3.0.1",
19 | "googleapis": "^20.1.0",
20 | "handlebars": "^4.0.10",
21 | "moment": "^2.18.1",
22 | "mongoose": "^4.11.7",
23 | "qs": "^6.5.0",
24 | "request": "^2.81.0",
25 | "stripe": "^4.24.0",
26 | "winston": "^2.3.1"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/cronJobs/serverless.yml:
--------------------------------------------------------------------------------
1 | # Welcome to Serverless!
2 | #
3 | # This file is the main config file for your service.
4 | # It's very minimal at this point and uses default values.
5 | # You can always add more config options for more control.
6 | # We've included some commented out config examples here.
7 | # Just uncomment any of them to get that config option.
8 | #
9 | # For full config options, check the docs:
10 | # docs.serverless.com
11 | #
12 | # Happy Coding!
13 |
14 | service: cronJobs
15 |
16 | # You can pin your service to only deploy with a specific Serverless version
17 | # Check out our docs for more details
18 | # frameworkVersion: "=X.X.X"
19 |
20 | provider:
21 | name: aws
22 | runtime: nodejs6.10
23 | region: us-east-1
24 | memory: 1536
25 | timeout: 300
26 | exclude:
27 | - src/**
28 | - webpack.config.js
29 |
30 | # you can overwrite defaults here
31 | # stage: dev
32 |
33 | # you can add statements to the Lambda function's IAM Role here
34 | # iamRoleStatements:
35 | # - Effect: "Allow"
36 | # Action:
37 | # - "s3:ListBucket"
38 | # Resource: { "Fn::Join" : ["", ["arn:aws:s3:::", { "Ref" : "ServerlessDeploymentBucket" } ] ] }
39 | # - Effect: "Allow"
40 | # Action:
41 | # - "s3:PutObject"
42 | # Resource:
43 | # Fn::Join:
44 | # - ""
45 | # - - "arn:aws:s3:::"
46 | # - "Ref" : "ServerlessDeploymentBucket"
47 | # - "/*"
48 |
49 | # you can define service wide environment variables here
50 | # environment:
51 | # variable1: value1
52 |
53 | # you can add packaging information here
54 | #package:
55 | # include:
56 | # - include-me.js
57 | # - include-me-dir/**
58 | # exclude:
59 | # - exclude-me.js
60 | # - exclude-me-dir/**
61 |
62 | functions:
63 | checkSentEmails:
64 | handler: handler.checkSentEmailsCron
65 | environment:
66 | NODE_ENV: production
67 | events:
68 | - schedule: rate(5 minutes)
69 |
70 | # The following are a few example events you can configure
71 | # NOTE: Please make sure to change your handler code to work with those events
72 | # Check the event documentation for details
73 | # events:
74 | # - http:
75 | # path: users/create
76 | # method: get
77 | # - s3: ${env:BUCKET}
78 | # - schedule: rate(10 minutes)
79 | # - sns: greeter-topic
80 | # - stream: arn:aws:dynamodb:region:XXXXXX:table/foo/stream/1970-01-01T00:00:00.000
81 | # - alexaSkill
82 | # - iot:
83 | # sql: "SELECT * FROM 'some_topic'"
84 | # - cloudwatchEvent:
85 | # event:
86 | # source:
87 | # - "aws.ec2"
88 | # detail-type:
89 | # - "EC2 Instance State-change Notification"
90 | # detail:
91 | # state:
92 | # - pending
93 | # - cloudwatchLog: '/aws/lambda/hello'
94 | # - cognitoUserPool:
95 | # pool: MyUserPool
96 | # trigger: PreSignUp
97 |
98 | # Define function environment variables here
99 | # environment:
100 | # variable2: value2
101 |
102 | # you can add CloudFormation resource templates here
103 | #resources:
104 | # Resources:
105 | # NewResource:
106 | # Type: AWS::S3::Bucket
107 | # Properties:
108 | # BucketName: my-new-bucket
109 | # Outputs:
110 | # NewOutput:
111 | # Description: "Description for the output"
112 | # Value: "Some output value"
113 |
--------------------------------------------------------------------------------
/cronJobs/src/lib/getRootURL.js:
--------------------------------------------------------------------------------
1 | ../../../webApp/lib/getRootURL.js
--------------------------------------------------------------------------------
/cronJobs/src/server:
--------------------------------------------------------------------------------
1 | ../../webApp/server
--------------------------------------------------------------------------------
/cronJobs/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | target: 'node',
3 | entry: './src/index.js',
4 | output: {
5 | libraryTarget: 'commonjs',
6 | filename: 'handler.js'
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "NODE_ENV": "production"
4 | },
5 |
6 | "dotenv": true
7 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "harbor-lambda",
3 | "version": "0.0.1",
4 | "devDependencies": {
5 | "babel-cli": "^6.24.1",
6 | "babel-eslint": "^7.2.3",
7 | "babel-preset-env": "^1.6.0",
8 | "eslint": "^3.19.0",
9 | "eslint-config-airbnb": "^15.0.1",
10 | "eslint-plugin-import": "^2.6.0",
11 | "eslint-plugin-jsx-a11y": "^5.0.3",
12 | "eslint-plugin-react": "^7.1.0"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/webApp/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["env", "next/babel"],
3 | "plugins": [["transform-define", "./env-config.js"]]
4 | }
5 |
--------------------------------------------------------------------------------
/webApp/components/CopyButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import ClipboardButton from 'react-clipboard.js'
4 |
5 | import { success } from '../lib/notifier'
6 |
7 | const styleCopyButton = {
8 | textTransform: 'none',
9 | font: '15px Muli',
10 | fontWeight: '300',
11 | letterSpacing: '0.01em',
12 | color: '#1F4167',
13 | backgroundColor: 'transparent',
14 | boxShadow: 'none',
15 | WebkitBoxShadow: 'none',
16 | MozBoxShadow: 'none',
17 | border: 'none',
18 | marginLeft: '25px',
19 | '&:hover': {
20 | opacity: '0.8'
21 | }
22 | }
23 |
24 | const CopyButton = ({ content, buttonText }) =>
25 | (
26 | success('Copied!')}
30 | >
31 | {buttonText}
32 |
33 | )
34 |
35 | CopyButton.propTypes = {
36 | content: PropTypes.string.isRequired,
37 | buttonText: PropTypes.string.isRequired
38 | }
39 |
40 | export default CopyButton
41 |
--------------------------------------------------------------------------------
/webApp/components/DescriptionEditor.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import Link from 'material-ui-icons/Link'
4 | import Button from 'material-ui/Button'
5 | import Dialog, { DialogActions, DialogContent, DialogContentText } from 'material-ui/Dialog'
6 |
7 | import { styleExternalLinkIcon, styleFlatButton } from '../components/SharedStyles'
8 |
9 | import { error } from '../lib/notifier'
10 |
11 | const styleTitle = {
12 | marginLeft: '25px',
13 | fontSize: '18px'
14 | }
15 |
16 | const styleDialogActions = {
17 | margin: '0px 20px 20px 0px'
18 | }
19 |
20 | function replaceSelectionWithHtml(html, range) {
21 | range.deleteContents()
22 | const div = document.createElement('div')
23 | div.innerHTML = html
24 | const frag = document.createDocumentFragment()
25 |
26 | let child = div.firstChild
27 |
28 | while (child) {
29 | frag.appendChild(child)
30 | child = div.firstChild
31 | }
32 |
33 | range.insertNode(frag)
34 | }
35 |
36 | class EditorDiv extends Component {
37 | static propTypes = {
38 | defaultValue: PropTypes.string.isRequired,
39 | onChange: PropTypes.func.isRequired
40 | }
41 |
42 | shouldComponentUpdate() {
43 | return false
44 | }
45 |
46 | render() {
47 | const { defaultValue } = this.props
48 |
49 | return (
50 |
{
56 | const text = e.target.innerText
57 |
58 | if (e.which === 8 || e.ctrlKey) {
59 | return
60 | }
61 |
62 | if (text.length > 250) {
63 | e.preventDefault()
64 | error('Description should be 250 characters or less.')
65 | }
66 | }}
67 | onInput={e => {
68 | this.props.onChange(e)
69 | }}
70 | ref={elm => {
71 | this.elm = elm
72 | }}
73 | />
74 | )
75 | }
76 | }
77 |
78 | class DescriptionEditor extends Component {
79 | static propTypes = {
80 | defaultValue: PropTypes.string.isRequired,
81 | onRendered: PropTypes.func.isRequired,
82 | onChange: PropTypes.func.isRequired
83 | }
84 |
85 | state = {
86 | insertLinkOpen: false
87 | }
88 |
89 | componentDidMount() {
90 | this.props.onRendered(this)
91 | }
92 |
93 | handleRequestClose = () => {
94 | this.setState({ insertLinkOpen: false })
95 | }
96 |
97 | handleOpenInsertLinkDialog = () => {
98 | const range = window.getSelection().getRangeAt(0)
99 | if (range.toString() === '') {
100 | error('Select text to hyperlink it.')
101 | return
102 | }
103 |
104 | this.range = range
105 | this.setState({ insertLinkOpen: true })
106 | }
107 |
108 | handleInsertLink = () => {
109 | const link = this.insertLinkInput.value
110 | if (link === '') {
111 | error('Add link')
112 | return
113 | }
114 |
115 | this.insertLinkInput.value = ''
116 | replaceSelectionWithHtml(
117 | `
${this.range} `,
118 | this.range,
119 | this.editorDiv.elm
120 | )
121 |
122 | this.props.onChange({ target: this.editorDiv.elm })
123 | this.setState({ insertLinkOpen: false })
124 | }
125 |
126 | render() {
127 | return (
128 |
160 | )
161 | }
162 | }
163 |
164 | export default DescriptionEditor
165 |
--------------------------------------------------------------------------------
/webApp/components/Header.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types'
2 | import Link from 'next/link'
3 | import Router from 'next/router'
4 | import NProgress from 'nprogress'
5 | import Launch from 'material-ui-icons/Launch'
6 | import Toolbar from 'material-ui/Toolbar'
7 | import Grid from 'material-ui/Grid'
8 | import Hidden from 'material-ui/Hidden'
9 | import Button from 'material-ui/Button'
10 | import MenuDrop from './MenuDrop'
11 | import Income from './IncomeReport'
12 |
13 | import {
14 | styleFlatButton,
15 | styleRaisedButton,
16 | styleToolbar,
17 | styleExternalLinkIcon
18 | } from './SharedStyles'
19 |
20 | Router.onRouteChangeStart = () => {
21 | NProgress.start()
22 | }
23 | Router.onRouteChangeComplete = () => NProgress.done()
24 | Router.onRouteChangeError = () => NProgress.done()
25 |
26 | const optionsMenu = [
27 | {
28 | text: 'Send feedback',
29 | url: 'https://mail.google.com/mail/?view=cm&fs=1&to=team@findharbor.com',
30 | target: '_blank',
31 | rel: 'noopener noreferrer'
32 | },
33 | { text: 'Log out', url: '/logout', target: '_self', rel: null }
34 | ]
35 |
36 | function Header({ user }) {
37 | if (user) {
38 | return (
39 |
40 |
41 |
42 |
43 |
44 |
45 | Settings
46 |
47 | {' '}
48 |
49 | {user.isAdmin ? (
50 |
51 | Mentors
52 |
53 | ) : null}
54 | {' '}
55 |
56 |
57 | {!user.isStripeConnected ? (
58 |
65 | ) : (
66 |
78 | )}
79 |
80 |
81 |
82 | {user.avatarUrl ? (
83 |
84 | ) : null}
85 |
86 |
87 |
88 |
89 |
90 | )
91 | }
92 |
93 | return (
94 |
95 |
96 |
97 |
98 |
99 |
100 | Log in
101 |
102 |
103 |
104 |
105 | Sign up
106 |
107 |
108 |
109 |
110 |
111 |
112 | )
113 | }
114 |
115 | Header.propTypes = {
116 | user: PropTypes.shape({
117 | displayName: PropTypes.string,
118 | email: PropTypes.string.isRequired
119 | })
120 | }
121 |
122 | Header.defaultProps = {
123 | user: null
124 | }
125 |
126 | export default Header
127 |
--------------------------------------------------------------------------------
/webApp/components/IncomeReport.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import NProgress from 'nprogress'
3 | import PropTypes from 'prop-types'
4 | import { withStyles } from 'material-ui/styles'
5 | import List, { ListItem, ListItemText } from 'material-ui/List'
6 | import Menu, { MenuItem } from 'material-ui/Menu'
7 | import KeyboardArrowDown from 'material-ui-icons/KeyboardArrowDown'
8 | import { getIncomeReport } from '../lib/api'
9 | import { error } from '../lib/notifier'
10 |
11 | const styleSheet = {
12 | root: {
13 | width: '100%',
14 | maxWidth: '200px',
15 | display: 'inline-flex',
16 | marginRight: '20px',
17 | backgroundColor: '#F7F9FC'
18 | },
19 | List: {
20 | padding: '0px',
21 | border: '1px solid rgba(26, 35, 126, 0.25)'
22 | },
23 | MenuItem: {
24 | font: '14px Muli',
25 | fontWeight: '400'
26 | },
27 | ListItem: {
28 | padding: '2px 5px',
29 | textAlign: 'center'
30 | },
31 | ListItemText: {
32 | font: '14px Muli',
33 | fontWeight: '400',
34 | padding: '0px 5px'
35 | }
36 | }
37 |
38 | class IncomeReport extends Component {
39 | static propTypes = {
40 | classes: PropTypes.shape({}).isRequired
41 | }
42 |
43 | state = {
44 | anchorEl: undefined,
45 | open: false,
46 | selectedIndex: 0,
47 | loading: true,
48 | error: null,
49 | incomeList: []
50 | }
51 |
52 | componentDidMount() {
53 | NProgress.start()
54 |
55 | getIncomeReport()
56 | .then(({ incomeList }) => {
57 | this.setState({ incomeList, loading: false })
58 | NProgress.done()
59 | })
60 | .catch(err => {
61 | this.setState({ loading: false, error: err.message || err.toString() })
62 | error(err)
63 | NProgress.done()
64 | })
65 | }
66 |
67 | handleClickListItem = event => {
68 | this.setState({ open: true, anchorEl: event.currentTarget })
69 | }
70 |
71 | handleMenuItemClick = (event, index) => {
72 | this.setState({ selectedIndex: index, open: false })
73 | }
74 |
75 | handleRequestClose = () => {
76 | this.setState({ open: false })
77 | }
78 |
79 | render() {
80 | if (this.state.loading) {
81 | return null
82 | }
83 |
84 | if (this.state.error) {
85 | return
{this.state.error}
86 | }
87 |
88 | const classes = this.props.classes
89 |
90 | const options = this.state.incomeList
91 | const selectedIndex = this.state.selectedIndex
92 | const selectedItem = options[selectedIndex]
93 |
94 | return (
95 |
96 |
97 |
103 |
108 |
109 |
110 |
111 |
117 | {options.map((option, index) => (
118 | this.handleMenuItemClick(event, index)}
123 | >
124 | ${option.income} in {option.month}
125 |
126 | ))}
127 |
128 |
129 | )
130 | }
131 | }
132 |
133 | const IncomeReportWithStyle = withStyles(styleSheet)(IncomeReport)
134 |
135 | const Income = () =>
136 |
137 | export default Income
138 |
--------------------------------------------------------------------------------
/webApp/components/MenuDrop.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import Menu from 'material-ui/Menu'
4 |
5 | import { mentorHeaderPic } from './SharedStyles'
6 |
7 | class MenuDrop extends Component {
8 | static propTypes = {
9 | src: PropTypes.string.isRequired,
10 | alt: PropTypes.string.isRequired,
11 | options: PropTypes.arrayOf(String).isRequired
12 | }
13 |
14 | state = {
15 | open: false,
16 | anchorEl: undefined
17 | }
18 |
19 | button = undefined
20 |
21 | handleClick = event => {
22 | this.setState({ open: true, anchorEl: event.currentTarget })
23 | }
24 |
25 | handleRequestClose = () => {
26 | this.setState({ open: false })
27 | }
28 |
29 | render() {
30 | const { options, src, alt } = this.props
31 |
32 | return (
33 |
34 |
42 |
64 |
65 | )
66 | }
67 | }
68 |
69 | export default MenuDrop
70 |
--------------------------------------------------------------------------------
/webApp/components/Modal.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { withStyles } from 'material-ui/styles'
4 | import List, { ListItem, ListItemText } from 'material-ui/List'
5 | import Dialog, { DialogTitle } from 'material-ui/Dialog'
6 | import Slide from 'material-ui/transitions/Slide'
7 | import Button from 'material-ui/Button'
8 |
9 | const styleRaisedButton = {
10 | borderRadius: '0px',
11 | textTransform: 'none',
12 | font: '15px Muli',
13 | fontWeight: '600',
14 | letterSpacing: '0.01em',
15 | color: 'white',
16 | backgroundColor: '#1a237e'
17 | }
18 |
19 | const styleSheet = {
20 | Dialog: {
21 | font: '15px Muli'
22 | // width: '360px'
23 | },
24 | DialogTitle: {
25 | font: '15px Muli',
26 | fontSize: '20px',
27 | fontWeight: '300',
28 | padding: '20px'
29 | },
30 | ListItemText: {
31 | font: '15px Muli',
32 | fontWeight: '300'
33 | }
34 | }
35 |
36 | const SimpleDialog = ({ options, classes, onRequestClose, title, ...other }) => (
37 |
}
42 | >
43 |
44 | {title}
45 |
46 |
47 |
48 | {options.map(option => (
49 |
50 |
51 |
52 | ))}
53 |
54 |
55 |
56 | OK, got it
57 |
58 |
59 |
60 |
61 |
62 | )
63 |
64 | SimpleDialog.propTypes = {
65 | options: PropTypes.arrayOf(String).isRequired,
66 | classes: PropTypes.shape({}).isRequired,
67 | onRequestClose: PropTypes.func.isRequired,
68 | title: PropTypes.string.isRequired
69 | }
70 |
71 | const SimpleDialogWrapped = withStyles(styleSheet)(SimpleDialog)
72 |
73 | class Modal extends Component {
74 | static propTypes = {
75 | linkText: PropTypes.string.isRequired,
76 | title: PropTypes.string.isRequired,
77 | options: PropTypes.arrayOf(String).isRequired
78 | }
79 |
80 | state = {
81 | open: false
82 | }
83 |
84 | handleRequestClose = () => {
85 | this.setState({ open: false })
86 | }
87 |
88 | render() {
89 | const { linkText, title } = this.props
90 | const options = this.props.options
91 | return (
92 |
93 | this.setState({ open: true })}
97 | >
98 | {linkText}
99 |
100 |
106 |
107 | )
108 | }
109 | }
110 |
111 | export default Modal
112 |
--------------------------------------------------------------------------------
/webApp/components/Notifier.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import Snackbar from 'material-ui/Snackbar'
3 |
4 | let openSnackbarFn
5 |
6 | class Notifier extends Component {
7 | state = {
8 | open: false,
9 | type: 'success',
10 | message: ''
11 | }
12 |
13 | componentDidMount() {
14 | openSnackbarFn = this.openSnackbar
15 | }
16 |
17 | handleSnackbarRequestClose = () => {
18 | this.setState({
19 | open: false,
20 | message: '',
21 | type: 'success'
22 | })
23 | }
24 |
25 | openSnackbar = ({ message, type }) => {
26 | this.setState({ open: true, message, type })
27 | }
28 |
29 | render() {
30 | const message = (
31 |
32 | )
33 |
34 | return (
35 |
45 | )
46 | }
47 | }
48 |
49 | export function openSnackbar({ message, type }) {
50 | openSnackbarFn({ message, type })
51 | }
52 |
53 | export default Notifier
54 |
--------------------------------------------------------------------------------
/webApp/components/SelectList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import { withStyles } from 'material-ui/styles'
4 | import List, { ListItem, ListItemText } from 'material-ui/List'
5 | import Menu, { MenuItem } from 'material-ui/Menu'
6 | import KeyboardArrowDown from 'material-ui-icons/KeyboardArrowDown'
7 |
8 | const styleSheet = {
9 | root: {
10 | width: '100%',
11 | maxWidth: '200px',
12 | textAlign: 'right',
13 | backgroundColor: 'white'
14 | },
15 | List: {
16 | padding: '0px',
17 | border: '1px solid rgba(26, 35, 126, 0.25)',
18 | marginBottom: '5px'
19 | },
20 | MenuItem: {
21 | font: '14px Muli',
22 | fontWeight: '400'
23 | },
24 | ListItem: {
25 | padding: '8px 5px',
26 | textAlign: 'center'
27 | },
28 | ListItemText: {
29 | font: '14px Muli',
30 | fontWeight: '300',
31 | padding: '0px 5px'
32 | }
33 | }
34 |
35 | class SelectMenuList extends Component {
36 | static propTypes = {
37 | options: PropTypes.arrayOf(String).isRequired,
38 | values: PropTypes.arrayOf(String),
39 | onClick: PropTypes.func,
40 | secondary: PropTypes.string.isRequired,
41 | classes: PropTypes.shape({}).isRequired,
42 | selectedIndex: PropTypes.number
43 | }
44 |
45 | static defaultProps = {
46 | values: null,
47 | selectedIndex: null,
48 | onClick: null
49 | }
50 |
51 | state = {
52 | anchorEl: undefined,
53 | open: false,
54 | selectedIndex: 0
55 | }
56 |
57 | button = undefined
58 |
59 | handleClickListItem = event => {
60 | this.setState({ open: true, anchorEl: event.currentTarget })
61 | }
62 |
63 | handleMenuItemClick = (event, index) => {
64 | this.setState({ selectedIndex: index, open: false })
65 | const { onClick, values } = this.props
66 |
67 | if (onClick && values) {
68 | onClick(values[index])
69 | }
70 | }
71 |
72 | handleRequestClose = () => {
73 | this.setState({ open: false })
74 | }
75 |
76 | render() {
77 | const classes = this.props.classes
78 | const options = this.props.options
79 |
80 | const { secondary } = this.props
81 | const selectedIndex = this.props.selectedIndex || this.state.selectedIndex
82 |
83 | return (
84 |
85 |
86 |
93 |
99 |
100 |
101 |
102 |
119 |
120 | )
121 | }
122 | }
123 |
124 | const SelectMenuListWithStyle = withStyles(styleSheet)(SelectMenuList)
125 |
126 | const SelectList = ({ options, values, onClick, secondary, selectedIndex }) => (
127 |
134 | )
135 |
136 | SelectList.propTypes = {
137 | options: PropTypes.arrayOf(String).isRequired,
138 | values: PropTypes.arrayOf(String).isRequired,
139 | onClick: PropTypes.func.isRequired,
140 | secondary: PropTypes.string.isRequired,
141 | selectedIndex: PropTypes.number.isRequired
142 | }
143 |
144 | export default SelectList
145 |
--------------------------------------------------------------------------------
/webApp/components/SharedStyles.js:
--------------------------------------------------------------------------------
1 | // shared style
2 |
3 | const styleToolbar = {
4 | background: '#FFF',
5 | height: '64px',
6 | paddingRight: '20px'
7 | }
8 |
9 | const styleMainNavLink = {
10 | margin: '0px 20px'
11 | }
12 |
13 | const mentorHeaderPic = {
14 | borderRadius: '50%',
15 | width: '42px',
16 | height: '42px',
17 | margin: '3px 0px 0px 0px'
18 | }
19 |
20 | const mentorPagePic = {
21 | borderRadius: '50%',
22 | width: '72px',
23 | height: '72px',
24 | margin: '15px 5px 0px'
25 | }
26 |
27 | const styleExternalLinkIcon = {
28 | width: '16px',
29 | opacity: '0.7',
30 | verticalAlign: 'middle',
31 | margin: '0px 0px 3px 4px'
32 | }
33 |
34 | const styleFlatButton = {
35 | borderRadius: '2px',
36 | textTransform: 'none',
37 | font: '15px Muli',
38 | fontWeight: '400',
39 | letterSpacing: '0.01em',
40 | color: '#0D47A1',
41 | backgroundColor: 'transparent'
42 | }
43 |
44 | const styleRaisedButton = {
45 | borderRadius: '2px',
46 | textTransform: 'none',
47 | font: '15px Muli',
48 | fontWeight: '600',
49 | letterSpacing: '0.01em',
50 | color: 'white',
51 | backgroundColor: '#0D47A1',
52 | '&:hover': {
53 | backgroundColor: 'white'
54 | }
55 | }
56 |
57 | const styleLoginButton = {
58 | borderRadius: '2px',
59 | textTransform: 'none',
60 | font: '15px Muli',
61 | fontWeight: '600',
62 | letterSpacing: '0.01em',
63 | color: 'white',
64 | backgroundColor: '#DF4930'
65 | }
66 |
67 | const styleRaisedFullWidthButton = {
68 | borderRadius: '2px',
69 | textTransform: 'none',
70 | font: '16px Muli',
71 | fontWeight: '300',
72 | letterSpacing: '0.01em',
73 | color: 'white',
74 | backgroundColor: '#1a237e',
75 | width: '100%',
76 | margin: '20px auto',
77 | '&:hover': {
78 | backgroundColor: 'white'
79 | }
80 | }
81 |
82 | const styleTextField = {
83 | font: '15px Muli',
84 | color: '#222',
85 | fontWeight: '300'
86 | }
87 |
88 | const styleForm = {
89 | margin: '7% auto',
90 | width: '360px'
91 | }
92 |
93 | const styleMainDiv = {
94 | width: '90%',
95 | margin: '25px auto'
96 | }
97 |
98 | const styleTextCenter = {
99 | textAlign: 'right'
100 | }
101 |
102 | module.exports = {
103 | styleToolbar,
104 | styleMainNavLink,
105 | mentorHeaderPic,
106 | mentorPagePic,
107 | styleExternalLinkIcon,
108 | styleFlatButton,
109 | styleRaisedButton,
110 | styleLoginButton,
111 | styleRaisedFullWidthButton,
112 | styleTextField,
113 | styleForm,
114 | styleMainDiv,
115 | styleTextCenter
116 | }
117 |
--------------------------------------------------------------------------------
/webApp/components/Toggle.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import { FormControlLabel } from 'material-ui/Form'
4 | import Switch from 'material-ui/Switch'
5 |
6 | const Toggle = ({ checked, onChange, label }) =>
7 | (
8 | this.setState({ checked: checked })}
13 | onChange={onChange}
14 | />
15 | }
16 | label={label}
17 | />
18 |
)
19 |
20 | Toggle.propTypes = {
21 | checked: PropTypes.bool.isRequired,
22 | onChange: PropTypes.func.isRequired,
23 | label: PropTypes.string.isRequired
24 | }
25 |
26 | export default Toggle
27 |
--------------------------------------------------------------------------------
/webApp/env-config.js:
--------------------------------------------------------------------------------
1 | const prod = process.env.NODE_ENV === 'production'
2 |
3 | module.exports = {
4 | StripePublishableKey: prod
5 | ? 'pk_XXXXXX'
6 | : 'pk_XXXXXX'
7 | }
8 |
--------------------------------------------------------------------------------
/webApp/lib/api.js:
--------------------------------------------------------------------------------
1 | import { updateUser } from './withAuth'
2 |
3 | const BASE_PATH = '/api/v1'
4 |
5 | export const changePrice = price =>
6 | fetch(`${BASE_PATH}/change-price`, {
7 | method: 'POST',
8 | credentials: 'include',
9 | headers: {
10 | 'Content-type': 'application/json; charset=UTF-8'
11 | },
12 | body: JSON.stringify({ price })
13 | })
14 | .then(res => res.json())
15 | .then(res => {
16 | if (res.error) {
17 | return Promise.reject(new Error(res.error))
18 | }
19 |
20 | updateUser({ price })
21 |
22 | return Promise.resolve(res)
23 | })
24 |
25 | export const changePageStatus = (isMentorPagePublic, cb) =>
26 | fetch(`${BASE_PATH}/change-status`, {
27 | method: 'POST',
28 | credentials: 'include',
29 | headers: {
30 | 'Content-type': 'application/json; charset=UTF-8'
31 | },
32 | body: JSON.stringify({ isMentorPagePublic })
33 | })
34 | .then(res => res.json())
35 | .then(res => {
36 | if (res.error) {
37 | if (cb) {
38 | cb(new Error(res.error))
39 | }
40 |
41 | return Promise.reject(new Error(res.error))
42 | }
43 |
44 | updateUser({ isMentorPagePublic })
45 | if (cb) {
46 | cb(null, res)
47 | }
48 |
49 | return Promise.resolve(res)
50 | })
51 | .catch(err => {
52 | if (cb) {
53 | cb(err)
54 | }
55 |
56 | return Promise.reject(err)
57 | })
58 |
59 | export const changeDescription = description =>
60 | fetch(`${BASE_PATH}/change-description`, {
61 | method: 'POST',
62 | credentials: 'include',
63 | headers: {
64 | 'Content-type': 'application/json; charset=UTF-8'
65 | },
66 | body: JSON.stringify({ description })
67 | })
68 | .then(res => res.json())
69 | .then(res => {
70 | if (res.error) {
71 | return Promise.reject(new Error(res.error))
72 | }
73 |
74 | updateUser({ description })
75 |
76 | return Promise.resolve(res)
77 | })
78 |
79 | export const updateProfile = () =>
80 | fetch(`${BASE_PATH}/update-profile`, {
81 | method: 'GET',
82 | credentials: 'include',
83 | headers: {
84 | 'Content-type': 'application/json; charset=UTF-8'
85 | }
86 | })
87 | .then(res => res.json())
88 | .then(res => {
89 | if (res.error) {
90 | if (res.isGoogleAccessRevokedError) {
91 | setTimeout(() => {
92 | document.location = '/login?consent=1'
93 | }, 1000)
94 |
95 | return Promise.reject(new Error('Re-login is required. Logging out...'))
96 | }
97 |
98 | return Promise.reject(new Error(res.error))
99 | }
100 |
101 | updateUser(res)
102 |
103 | return Promise.resolve(res)
104 | })
105 |
106 | export const checkLabelsAndFilters = () =>
107 | fetch(`${BASE_PATH}/check-labels-and-filters`, {
108 | method: 'GET',
109 | credentials: 'include',
110 | headers: {
111 | 'Content-type': 'application/json; charset=UTF-8'
112 | }
113 | })
114 | .then(res => res.json())
115 | .then(res => {
116 | if (res.error) {
117 | if (res.isGoogleAccessRevokedError) {
118 | setTimeout(() => {
119 | document.location = '/login?consent=1'
120 | }, 1000)
121 |
122 | return Promise.reject(new Error('Re-login is required. Logging out...'))
123 | }
124 |
125 | return Promise.reject(new Error(res.error))
126 | }
127 |
128 | return Promise.resolve(res)
129 | })
130 |
131 | export const getIncomeReport = () =>
132 | fetch(`${BASE_PATH}/get-income-report`, {
133 | credentials: 'include',
134 | headers: {
135 | 'Content-type': 'application/json; charset=UTF-8'
136 | }
137 | })
138 | .then(res => res.json())
139 | .then(res => {
140 | if (res.error) {
141 | return Promise.reject(new Error(res.error))
142 | }
143 |
144 | return Promise.resolve(res)
145 | })
146 |
147 | export const getMentorList = () =>
148 | fetch(`${BASE_PATH}/get-mentor-list`, {
149 | credentials: 'include',
150 | headers: {
151 | 'Content-type': 'application/json; charset=UTF-8'
152 | }
153 | })
154 | .then(res => res.json())
155 | .then(res => {
156 | if (res.error) {
157 | return Promise.reject(new Error(res.error))
158 | }
159 |
160 | return Promise.resolve(res)
161 | })
162 |
--------------------------------------------------------------------------------
/webApp/lib/context.js:
--------------------------------------------------------------------------------
1 | import { create } from 'jss'
2 | import preset from 'jss-preset-default'
3 | import { SheetsRegistry } from 'react-jss/lib/jss'
4 | import { createMuiTheme } from 'material-ui/styles'
5 | import { blue, indigo } from 'material-ui/colors'
6 | import createGenerateClassName from 'material-ui/styles/createGenerateClassName'
7 |
8 | const theme = createMuiTheme({
9 | palette: {
10 | primary: blue,
11 | secondary: indigo
12 | }
13 | })
14 |
15 | // Configure JSS
16 | const jss = create(preset())
17 | jss.options.createGenerateClassName = createGenerateClassName
18 |
19 | function createContext() {
20 | return {
21 | jss,
22 | theme,
23 | // This is needed in order to deduplicate the injection of CSS in the page.
24 | sheetsManager: new Map(),
25 | // This is needed in order to inject the critical CSS.
26 | sheetsRegistry: new SheetsRegistry()
27 | }
28 | }
29 |
30 | export default function getContext() {
31 | // Make sure to create a new store for every server-side request so that data
32 | // isn't shared between connections (which would be bad)
33 | if (!process.browser) {
34 | return createContext()
35 | }
36 |
37 | // Reuse context on the client-side
38 | if (!global.__INIT_MATERIAL_UI__) {
39 | global.__INIT_MATERIAL_UI__ = createContext()
40 | }
41 |
42 | return global.__INIT_MATERIAL_UI__
43 | }
44 |
--------------------------------------------------------------------------------
/webApp/lib/getRootURL.js:
--------------------------------------------------------------------------------
1 | export default function getRootURL() {
2 | const port = process.env.PORT || 8080
3 | const ROOT_URL =
4 | process.env.NODE_ENV === 'production'
5 | ? 'https://app.findharbor.com'
6 | : `http://localhost:${port}`
7 |
8 | return ROOT_URL
9 | }
10 |
--------------------------------------------------------------------------------
/webApp/lib/notifier.js:
--------------------------------------------------------------------------------
1 | import { openSnackbar } from '../components/Notifier'
2 |
3 | export function success(message) {
4 | openSnackbar({ message, type: 'success' })
5 | }
6 |
7 | export function error(err) {
8 | openSnackbar({ message: err.message || err.toString(), type: 'error' })
9 | }
10 |
--------------------------------------------------------------------------------
/webApp/lib/withAuth.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Router from 'next/router'
4 |
5 | let globalUser = null
6 |
7 | export function updateUser({ price, description, isMentorPagePublic }) {
8 | if (globalUser) {
9 | if (price) {
10 | globalUser.price = Number(price)
11 | }
12 |
13 | if (description) {
14 | globalUser.description = description
15 | }
16 |
17 | if (isMentorPagePublic !== undefined) {
18 | globalUser.isMentorPagePublic = isMentorPagePublic
19 | }
20 | }
21 | }
22 |
23 | export default (Page, { loginRequired = true, logoutRequired = false } = {}) =>
24 | class BaseComponent extends React.Component {
25 | static propTypes = {
26 | user: PropTypes.shape({
27 | displayName: PropTypes.string,
28 | email: PropTypes.string.isRequired
29 | }),
30 | isFromServer: PropTypes.bool.isRequired
31 | }
32 |
33 | static defaultProps = {
34 | user: null
35 | }
36 |
37 | static async getInitialProps(ctx) {
38 | const isFromServer = !!ctx.req
39 | const user = ctx.req ? ctx.req.user && ctx.req.user.toObject() : globalUser
40 |
41 | if (isFromServer && user) {
42 | user._id = user._id.toString()
43 | }
44 |
45 | const props = { user, isFromServer }
46 |
47 | if (Page.getInitialProps) {
48 | Object.assign(props, (await Page.getInitialProps(ctx)) || {})
49 | }
50 |
51 | return props
52 | }
53 |
54 | componentDidMount() {
55 | if (this.props.isFromServer) {
56 | globalUser = this.props.user
57 | }
58 |
59 | if (loginRequired && !logoutRequired && !this.props.user) {
60 | Router.push('/login')
61 | return
62 | }
63 |
64 | if (logoutRequired && this.props.user) {
65 | Router.push('/')
66 | }
67 | }
68 |
69 | render() {
70 | if (loginRequired && !logoutRequired && !this.props.user) {
71 | return null
72 | }
73 |
74 | if (logoutRequired && this.props.user) {
75 | return null
76 | }
77 |
78 | return
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/webApp/lib/withLayout.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { withStyles, MuiThemeProvider } from 'material-ui/styles'
3 | import getContext from '../lib/context'
4 |
5 | import Router from 'next/router'
6 |
7 | import Header from '../components/Header'
8 | import Notifier from '../components/Notifier'
9 |
10 | Router.onAppUpdated = function onAppUpdated(nextRoute) {
11 | location.href = nextRoute
12 | }
13 |
14 | const styles = theme => ({
15 | '@global': {
16 | html: {
17 | WebkitFontSmoothing: 'antialiased', // Antialiasing.
18 | MozOsxFontSmoothing: 'grayscale' // Antialiasing.
19 | },
20 | body: {
21 | font: '15px Muli',
22 | color: '#222',
23 | margin: '0px auto',
24 | fontWeight: '300',
25 | lineHeight: '1.5em',
26 | backgroundColor: '#F7F9FC'
27 | },
28 | span: {
29 | fontFamily: 'Muli !important'
30 | }
31 | }
32 | })
33 |
34 | let AppWrapper = props => props.children
35 |
36 | AppWrapper = withStyles(styles)(AppWrapper)
37 |
38 | function withLayout(BaseComponent, { noHeader = false } = {}) {
39 | class App extends React.Component {
40 | static getInitialProps(ctx) {
41 | if (BaseComponent.getInitialProps) {
42 | return BaseComponent.getInitialProps(ctx)
43 | }
44 |
45 | return {}
46 | }
47 |
48 | componentWillMount() {
49 | this.styleContext = getContext()
50 | }
51 |
52 | componentDidMount() {
53 | // Remove the server-side injected CSS.
54 | const jssStyles = document.querySelector('#jss-server-side')
55 | if (jssStyles && jssStyles.parentNode) {
56 | jssStyles.parentNode.removeChild(jssStyles)
57 | }
58 | }
59 |
60 | render() {
61 | return (
62 |
66 |
67 | {noHeader ? null :
}
68 |
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 | }
77 |
78 | return App
79 | }
80 |
81 | export default withLayout
82 |
--------------------------------------------------------------------------------
/webApp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "harbor",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "nodemon server/app.js --watch server --exec babel-node",
6 | "dev2": "USE_MONGO_TEST2=1 nodemon server/app.js --watch server --exec babel-node",
7 | "build": "next build",
8 | "start": "NODE_ENV=production ROOT_URL=https://app.findharbor.com babel-node server/app.js",
9 | "test-gmail-sent-email": "TEST_FROM_CLI=1 babel-node server/gmail/checkSentEmails.js",
10 | "lint": "eslint components pages lib server",
11 | "test": "jest --coverage"
12 | },
13 | "jest": {
14 | "coverageDirectory": "./.coverage",
15 | "roots": [
16 | "test/"
17 | ]
18 | },
19 | "dependencies": {
20 | "next": "^4.0.3",
21 | "react": "^16.0.0",
22 | "react-dom": "^16.0.0",
23 | "aws-sdk": "^2.102.0",
24 | "babel-cli": "^6.26.0",
25 | "babel-plugin-transform-define": "^1.3.0",
26 | "body-parser": "^1.17.2",
27 | "connect-mongo": "^2.0.0",
28 | "dotenv": "^4.0.0",
29 | "email-addresses": "^3.0.1",
30 | "express": "^4.15.4",
31 | "express-session": "^1.15.3",
32 | "googleapis": "^20.0.1",
33 | "handlebars": "^4.0.10",
34 | "isomorphic-fetch": "^2.2.1",
35 | "jss": "^8.1.0",
36 | "jss-preset-default": "^3.0.0",
37 | "lodash": "^4.17.4",
38 | "material-ui": "^1.0.0-alpha.21",
39 | "material-ui-icons": "^1.0.0-alpha.19",
40 | "moment": "^2.18.1",
41 | "mongoose": "^4.12.1",
42 | "nprogress": "^0.2.0",
43 | "passport": "^0.3.2",
44 | "passport-strategy": "^1.0.0",
45 | "prop-types": "^15.5.10",
46 | "qs": "^6.5.0",
47 | "react-clipboard.js": "^1.1.2",
48 | "react-jss": "^7.0.2",
49 | "react-stripe-checkout": "^2.6.3",
50 | "request": "^2.81.0",
51 | "sitemap": "^1.13.0",
52 | "stripe": "^4.24.0",
53 | "winston": "^2.3.1"
54 | },
55 | "devDependencies": {
56 | "babel-plugin-transform-define": "^1.3.0",
57 | "babel-preset-env": "^1.6.0",
58 | "jest": "^21.2.1",
59 | "nodemon": "^1.11.0"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/webApp/pages/_document.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Document, { Head, Main, NextScript } from 'next/document'
3 | import JssProvider from 'react-jss/lib/JssProvider'
4 | import getContext from '../lib/context'
5 |
6 | class MyDocument extends Document {
7 | render() {
8 | return (
9 |
10 |
11 |
15 |
16 | {/* Use minimum-scale=1 to enable GPU rasterization */}
17 |
24 |
25 |
26 | {/* PWA primary color */}
27 |
28 |
33 |
34 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | )
70 | }
71 | }
72 |
73 | MyDocument.getInitialProps = ctx => {
74 | // Get the context to collected side effects.
75 | const context = getContext()
76 | const page = ctx.renderPage(Component => props => (
77 |
78 |
79 |
80 | ))
81 |
82 | return {
83 | ...page,
84 | stylesContext: context,
85 | styles: (
86 |
90 | )
91 | }
92 | }
93 |
94 | export default MyDocument
95 |
--------------------------------------------------------------------------------
/webApp/pages/checkout.js:
--------------------------------------------------------------------------------
1 | /* globals StripePublishableKey */
2 |
3 | import { Component } from 'react'
4 | import PropTypes from 'prop-types'
5 | import StripeCheckout from 'react-stripe-checkout'
6 | import 'isomorphic-fetch'
7 | import Error from 'next/error'
8 |
9 | import Grid from 'material-ui/Grid'
10 | import Button from 'material-ui/Button'
11 |
12 | import { error, success } from '../lib/notifier'
13 | import withLayout from '../lib/withLayout'
14 |
15 | const styleGrid = {
16 | flexGrow: '1'
17 | }
18 |
19 | const styleNextButton = {
20 | borderRadius: '2px',
21 | textTransform: 'none',
22 | font: '15px Muli',
23 | fontWeight: '600',
24 | letterSpacing: '0.01em',
25 | color: 'white',
26 | backgroundColor: '#0D47A1',
27 | '&:hover': {
28 | backgroundColor: 'white'
29 | },
30 | display: 'none'
31 | }
32 |
33 | class Checkout extends Component {
34 | static propTypes = {
35 | mentor: PropTypes.shape({}),
36 | payment: PropTypes.shape({})
37 | }
38 |
39 | static defaultProps = {
40 | mentor: null,
41 | payment: null
42 | }
43 |
44 | static async getInitialProps({ query }) {
45 | return query
46 | }
47 |
48 | onToken = token => {
49 | const { mentor, payment } = this.props
50 |
51 | if (!mentor.isStripeConnected) {
52 | error('Mentor has not connected Stripe account.')
53 | return
54 | }
55 |
56 | const url = `/public-api/v1/pay-payment/${payment._id}`
57 |
58 | fetch(url, {
59 | method: 'POST',
60 | headers: {
61 | 'Content-type': 'application/json; charset=UTF-8'
62 | },
63 | body: JSON.stringify({
64 | stripeToken: token
65 | })
66 | })
67 | .then(response => response.json())
68 | .then(data => {
69 | if (data.error) {
70 | error(data.error)
71 | } else {
72 | success('Success! Payment is sent, now you can send emails via Harbor again.')
73 | }
74 | })
75 | .catch(err => error(err))
76 | }
77 |
78 | render() {
79 | const { payment, mentor } = this.props
80 | if (!payment) {
81 | return
82 | }
83 |
84 | const descriptionStripe = `Pay $${payment.amount} to ${mentor.displayName}.`
85 | const buttonStripe = `Pay $${payment.amount} to ${mentor.displayName}`
86 |
87 | return (
88 |
89 |
90 |
91 |
99 |
100 |
109 |
110 | Pay ${payment.amount} for advice you received from {mentor.displayName}
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | )
119 | }
120 | }
121 |
122 | export default withLayout(Checkout, { noHeader: true })
123 |
--------------------------------------------------------------------------------
/webApp/pages/contact.js:
--------------------------------------------------------------------------------
1 | /* globals StripePublishableKey */
2 |
3 | import { Component } from 'react'
4 | import PropTypes from 'prop-types'
5 | import Head from 'next/head'
6 | import StripeCheckout from 'react-stripe-checkout'
7 | import 'isomorphic-fetch'
8 | import Error from 'next/error'
9 |
10 | import Grid from 'material-ui/Grid'
11 | import Paper from 'material-ui/Paper'
12 | import TextField from 'material-ui/TextField'
13 | import Button from 'material-ui/Button'
14 | import List, { ListItem } from 'material-ui/List'
15 | import Divider from 'material-ui/Divider'
16 |
17 | import { error, success } from '../lib/notifier'
18 | import getRootURL from '../lib/getRootURL'
19 | import withAuth from '../lib/withAuth'
20 | import withLayout from '../lib/withLayout'
21 |
22 | import { mentorPagePic, styleTextField } from '../components/SharedStyles'
23 |
24 | const styleGrid = {
25 | flexGrow: '1'
26 | }
27 |
28 | const styleNextButton = {
29 | borderRadius: '2px',
30 | textTransform: 'none',
31 | font: '15px Muli',
32 | fontWeight: '600',
33 | letterSpacing: '0.01em',
34 | color: 'white',
35 | backgroundColor: '#1a237e',
36 | '&:hover': {
37 | backgroundColor: 'white'
38 | }
39 | }
40 |
41 | const styleListItem = {
42 | display: 'block',
43 | textAlign: 'center'
44 | }
45 |
46 | const styleStats = {
47 | fontWeight: '300',
48 | margin: '0px'
49 | }
50 |
51 | const styleSectionSettings = {
52 | fontWeight: '300',
53 | margin: '25px 0px 10px 0px'
54 | }
55 |
56 | class Mentor extends Component {
57 | static propTypes = {
58 | mentor: PropTypes.shape({
59 | _id: PropTypes.string.isRequired
60 | }),
61 | user: PropTypes.shape({
62 | _id: PropTypes.string.isRequired
63 | })
64 | }
65 |
66 | static defaultProps = {
67 | mentor: null,
68 | user: null
69 | }
70 |
71 | static async getInitialProps({ req, query }) {
72 | let url = `/public-api/v1/get-mentor-detail/${query.slug}`
73 |
74 | if (req) {
75 | url = `${getRootURL()}${url}`
76 | }
77 |
78 | const res = await fetch(url)
79 | const json = await res.json()
80 |
81 | if (json.error) {
82 | return { mentor: null }
83 | }
84 | return { mentor: json }
85 | }
86 |
87 | state = {
88 | subject: '',
89 | message: ''
90 | }
91 |
92 | componentDidMount() {
93 | // const stripeToken = {
94 | // card: {
95 | // brand: 'Visa',
96 | // exp_month: 12,
97 | // exp_year: 2021,
98 | // id: 'card_1Aby7YGZO3FgrOB',
99 | // last4: '4242'
100 | // },
101 | // email: 'pdelgermurun@gmail.com',
102 | // id: 'tok_1Aby7YGZO3FgrO6o'
103 | // }
104 | // const url = '/public-api/v1/save-payment-card'
105 | // fetch(url, {
106 | // method: 'POST',
107 | // headers: {
108 | // 'Content-type': 'application/json; charset=UTF-8'
109 | // },
110 | // body: JSON.stringify({ stripeToken, mentorId: this.props.mentor._id })
111 | // })
112 | // .then(response => response.json())
113 | // .then(data => {
114 | // console.log(data)
115 | // })
116 | }
117 |
118 | onToken = token => {
119 | const { subject, message } = this.state
120 | const { mentor } = this.props
121 |
122 | if (!mentor.isStripeConnected) {
123 | error('Mentor has not connected Stripe account.')
124 | return
125 | }
126 |
127 | const url = '/public-api/v1/create-payment'
128 |
129 | fetch(url, {
130 | method: 'POST',
131 | headers: {
132 | 'Content-type': 'application/json; charset=UTF-8'
133 | },
134 | body: JSON.stringify({
135 | stripeToken: token,
136 | mentorId: this.props.mentor._id,
137 | email: { subject, message }
138 | })
139 | })
140 | .then(response => response.json())
141 | .then(data => {
142 | if (data.error) {
143 | error(data.error)
144 | } else {
145 | success('Success! Email is sent.')
146 | this.setState({ subject: '', message: '' })
147 | }
148 | })
149 | .catch(err => error(err))
150 | }
151 |
152 | onStripeCheckoutClicked = e => {
153 | const { subject, message } = this.state
154 |
155 | if (!subject || !message) {
156 | e.stopPropagation()
157 | error('Please fill out both Subject and Message.')
158 | }
159 |
160 | if (message.length > 1000) {
161 | e.stopPropagation()
162 | error('Message should be 1000 characters or less.')
163 | }
164 | }
165 |
166 | renderEmailForm() {
167 | return (
168 |
169 | this.setState({ subject: e.target.value })}
172 | type="text"
173 | label="Subject"
174 | labelClassName="textFieldLabel"
175 | InputClassName="textFieldInput"
176 | style={styleTextField}
177 | required
178 | />
179 |
180 |
181 | {
184 | if (e.target.value.length <= 1000) {
185 | this.setState({ message: e.target.value })
186 | }
187 | }}
188 | type="text"
189 | label="Message"
190 | placeholder="Message should be 1000 characters or less."
191 | labelClassName="textFieldLabel"
192 | InputClassName="textFieldInput"
193 | style={styleTextField}
194 | fullWidth
195 | multiline
196 | rows="8"
197 | required
198 | />
199 |
200 | {this.state.message.length} / 1000
201 |
202 |
203 | )
204 | }
205 |
206 | render() {
207 | const { mentor, user } = this.props
208 | const buttonStripe = `Email ${mentor.displayName}`
209 | const descriptionStripe = `You pay $${mentor.price} only if I reply.`
210 | const rating = `${mentor.rating.totalCount === 0
211 | ? 100
212 | : Math.round(mentor.rating.recommendCount / mentor.rating.totalCount * 100)}`
213 |
214 | if (!mentor) {
215 | return
216 | }
217 |
218 | if (
219 | (!mentor.isMentorPagePublic || !mentor.isStripeConnected) &&
220 | (!user || user._id !== mentor._id)
221 | ) {
222 | return
223 | }
224 |
225 | return (
226 |
227 |
228 |
Email {mentor.displayName} and ask for advice.
229 |
233 |
234 |
235 |
236 |
244 |
245 |
246 |
247 |
{mentor.displayName}
248 |
249 |
250 |
251 | {mentor.description ? (
252 |
253 | ) : (
254 | `${mentor.displayName} has not added description yet.`
255 | )}
256 |
257 |
258 | {mentor.links && mentor.links.length > 0 ? (
259 |
260 | Sample email advice:{' '}
261 | {mentor.links.map((l, i) => (
262 |
263 | {i !== 0 ? ', ' : null}
264 |
265 | {l.title}
266 |
267 |
268 | ))}
269 |
270 | ) : null}
271 |
272 |
273 |
274 | {rating}%
275 | of people recommend
276 |
277 |
278 |
279 | {mentor.repliedCount}
280 | email{mentor.repliedCount === 1 ? '' : 's'} sent
281 |
282 |
283 |
284 |
285 | <{mentor.averageResponseTime} hour{mentor.averageResponseTime === 1 ? '' : 's'}
286 |
287 | average reply time
288 |
289 |
290 |
291 |
292 |
293 |
301 |
302 |
303 |
Ask {mentor.displayName} for advice
304 |
305 |
306 | Step 1: Write a subject line and message for your email to {mentor.displayName}.
307 |
308 | Step 2: Enter your email address and add your payment information.
309 |
310 | Step 3: Verify your payment information and email {mentor.displayName}. You’ll
311 | be cc’d on the email.
312 |
313 |
314 |
315 | This advice costs ${mentor.price}. You'll only be charged if {mentor.displayName}{' '}
316 | replies.
317 |
318 |
319 |
320 |
321 |
Compose message
322 |
323 | {this.renderEmailForm()}
324 |
325 |
326 |
334 |
335 | Next
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 | )
345 | }
346 | }
347 |
348 | export default withAuth(withLayout(Mentor, { noHeader: true }), { loginRequired: false })
349 |
--------------------------------------------------------------------------------
/webApp/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import PropTypes from 'prop-types'
3 | import Head from 'next/head'
4 | import debounce from 'lodash/debounce'
5 |
6 | import InfoOutline from 'material-ui-icons/InfoOutline'
7 | import Launch from 'material-ui-icons/Launch'
8 | import Paper from 'material-ui/Paper'
9 | import Button from 'material-ui/Button'
10 |
11 | import DescriptionEditor from '../components/DescriptionEditor'
12 | import CopyButton from '../components/CopyButton'
13 | import Modal from '../components/Modal'
14 | import Toggle from '../components/Toggle'
15 | import SelectList from '../components/SelectList'
16 | import { styleExternalLinkIcon, styleFlatButton } from '../components/SharedStyles'
17 |
18 | import withAuth from '../lib/withAuth'
19 | import withLayout from '../lib/withLayout'
20 | import {
21 | changePrice,
22 | changeDescription,
23 | changePageStatus,
24 | updateProfile,
25 | checkLabelsAndFilters
26 | } from '../lib/api'
27 | import { success, error } from '../lib/notifier'
28 | import getRootURL from '../lib/getRootURL'
29 |
30 | const stylePaper = {
31 | padding: '1px 20px 20px 20px',
32 | margin: '20px 0px'
33 | }
34 |
35 | const styleSectionSettings = {
36 | textAlign: 'left',
37 | fontWeight: '400'
38 | }
39 |
40 | class Index extends React.Component {
41 | static propTypes = {
42 | user: PropTypes.shape({
43 | price: PropTypes.number,
44 | description: PropTypes.string,
45 | isStripeConnected: PropTypes.bool,
46 | isMentorPagePublic: PropTypes.bool
47 | }).isRequired
48 | }
49 |
50 | constructor(props, ...args) {
51 | super(props, ...args)
52 |
53 | this.state = {
54 | description: (props.user && props.user.description) || '',
55 | descriptionLength:
56 | (props.user && props.user.description && props.user.description.length) || 0,
57 | status: !!props.user && !!props.user.isMentorPagePublic
58 | }
59 |
60 | this.saveDescription = debounce(() => {
61 | changeDescription(this.editor.editorDiv.elm.innerHTML)
62 | .then(() => success('Saved'))
63 | .catch(err => error(err))
64 | }, 500)
65 | }
66 |
67 | onUpdateProfileClicked = () => {
68 | updateProfile()
69 | .then(() => success('Successfuly updated'))
70 | .catch(err => error(err))
71 | }
72 |
73 | checkLabelsAndFilters = () => {
74 | checkLabelsAndFilters()
75 | .then(() => success('Successfuly updated'))
76 | .catch(err => error(err))
77 | }
78 |
79 | changePrice = value => {
80 | this.setState({ price: Number(value) })
81 | changePrice(value)
82 | .then(() => success('Saved'))
83 | .catch(err => error(err))
84 | }
85 |
86 | changePageStatus = e => {
87 | this.setState({ status: e.target.checked })
88 | changePageStatus(e.target.checked)
89 | .then(() => success(this.state.status ? 'Saved' : 'Saved'))
90 | .catch(err => error(err))
91 | }
92 |
93 | render() {
94 | const { user } = this.props
95 |
96 | const price = this.state.price || user.price
97 | const status = this.state.status
98 | const description = this.state.description
99 | const label = this.state.status ? 'Page is published' : 'Page is unpublished'
100 | const options = ['$25', '$50', '$100']
101 | const values = ['25', '50', '100']
102 |
103 | const optionsModal = [
104 | '- Share your link on social media.',
105 | '- Add your link to your website, blog, book.',
106 | '- Send your link to people who email you for advice.',
107 | '- Email your link to newsletter subscribers or customers.',
108 | '- Include your link in your signature and auto-reply email.'
109 | ]
110 |
111 | const secondary = ` : You get paid $${price * 0.9}`
112 |
113 | return (
114 |
115 |
116 |
Settings on Harbor
117 |
121 |
122 | {!user.isStripeConnected ? (
123 |
124 | IMPORTANT : Connect your Stripe account to
125 | make your Harbor page public and to receive payments instantly.
126 |
127 | ) : (
128 |
129 | )}
130 |
131 |
132 |
133 | Your Harbor page
134 | success('Share this page with people who seek your advice.')}
137 | />
138 |
139 |
140 | {getRootURL()}/contact/{user.slug}
141 |
142 |
143 |
144 |
145 |
150 |
151 |
152 |
153 |
154 | Price
155 | success('Select a price for one email reply.')}
158 | />
159 |
160 |
167 |
168 |
169 |
170 |
171 | Description
172 | success('Describe yourself and the type of advice you offer.')}
175 | />
176 |
177 | {
180 | this.setState({
181 | descriptionLength: comp.editorDiv.elm.innerText.length
182 | })
183 | }}
184 | ref={elm => {
185 | this.editor = elm
186 | }}
187 | onChange={e => {
188 | if (e.target.innerText.length <= 250) {
189 | this.setState({ description: e.target.innerHTML })
190 | this.setState({ descriptionLength: e.target.innerText.length })
191 | this.saveDescription()
192 | } else {
193 | error('Description should be 250 characters or less.')
194 | }
195 | }}
196 | />
197 |
198 | {this.state.descriptionLength} / 250
199 |
200 |
201 |
202 |
203 |
204 | Name and Avatar
205 | success('Sync your current name and avatar with your Google profile.')}
208 | />
209 |
210 |
211 | Sync Name and Avatar
212 |
213 |
214 |
215 |
216 |
217 | Labels and Filters
218 |
221 | success(
222 | 'Regenerate Harbor labels and filters in your Gmail in case you deleted them by accident.'
223 | )}
224 | />
225 |
226 |
227 | Regenerate Labels and Filters in Gmail
228 |
229 |
230 |
231 | {user.isStripeConnected ? (
232 |
233 |
234 | Unpublish Harbor page
235 |
238 | success(
239 | 'If your page is unpublished, advice seekers will not be able to email you via Harbor.'
240 | )}
241 | />
242 | (DANGER ZONE)
243 |
244 |
248 |
249 | ) : null}
250 |
251 |
252 |
253 | Revoke access to Gmail
254 |
257 | success(
258 | 'If access is revoked, you will not be able to receive payment for your advice.'
259 | )}
260 | />
261 | (DANGER ZONE)
262 |
263 |
264 | Revoke Gmail access
265 |
266 |
267 |
268 | )
269 | }
270 | }
271 |
272 | export default withAuth(withLayout(Index))
273 |
--------------------------------------------------------------------------------
/webApp/pages/login.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import Button from 'material-ui/Button'
3 | import PropTypes from 'prop-types'
4 |
5 | import withAuth from '../lib/withAuth'
6 | import withLayout from '../lib/withLayout'
7 | import { styleLoginButton } from '../components/SharedStyles'
8 |
9 | const Login = props => (
10 |
11 |
12 |
Log in to Harbor
13 |
14 |
15 |
16 |
17 |
18 |
Log in
19 |
You’ll be logged in for 14 days unless you log out manually.
20 |
21 |
29 |
30 | Log in with Google
31 |
32 |
33 | )
34 | Login.propTypes = {
35 | url: PropTypes.shape({
36 | query: PropTypes.object
37 | }).isRequired
38 | }
39 |
40 | export default withAuth(withLayout(Login), { logoutRequired: true })
41 |
--------------------------------------------------------------------------------
/webApp/pages/mentors.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import PropTypes from 'prop-types'
3 | import Head from 'next/head'
4 | import Error from 'next/error'
5 |
6 | import Grid from 'material-ui/Grid'
7 | import Table, { TableBody, TableCell, TableHead, TableRow } from 'material-ui/Table'
8 |
9 | import { error } from '../lib/notifier'
10 | import withAuth from '../lib/withAuth'
11 | import withLayout from '../lib/withLayout'
12 | import { getMentorList } from '../lib/api'
13 |
14 | const styleGrid = {
15 | flexGrow: '1'
16 | }
17 |
18 | const UI = ({ mentors }) => (
19 |
20 |
21 |
Mentors on Harbor
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | Name
30 |
31 | Stripe status, Price, Number of Advice, Rating, Response Time
32 |
33 | Description
34 |
35 |
36 |
37 | {mentors.map(m => {
38 | const rating =
39 | !m.rating || m.rating.totalCount === 0
40 | ? 100
41 | : Math.round(m.rating.recommendCount / m.rating.totalCount * 100)
42 |
43 | return (
44 |
45 |
46 |
47 | {m.displayName}
48 | {' '}
49 | | {m.email}
50 |
51 |
52 |
53 | {m.isStripeConnected === true ? 'yes' : 'no'}, ${m.price}, {m.repliedCount}{' '}
54 | replies, {rating}%, {m.averageResponseTime} hour{m.averageResponseTime === 1 ? '' : 's'}
55 |
56 |
57 |
58 |
59 |
60 | )
61 | })}
62 |
63 |
64 |
65 |
66 | )
67 |
68 | UI.propTypes = {
69 | mentors: PropTypes.arrayOf(
70 | PropTypes.shape({
71 | _id: PropTypes.string
72 | })
73 | ).isRequired
74 | }
75 |
76 | class Mentors extends Component {
77 | static propTypes = {
78 | user: PropTypes.shape({
79 | isAdmin: PropTypes.bool
80 | })
81 | }
82 |
83 | static defaultProps = {
84 | user: null
85 | }
86 |
87 | static async getInitialProps({ query }) {
88 | return query
89 | }
90 |
91 | state = {
92 | mentors: []
93 | }
94 |
95 | componentDidMount() {
96 | getMentorList()
97 | .then(mentors => this.setState({ mentors }))
98 | .catch(err => error(err))
99 | }
100 |
101 | render() {
102 | const { user } = this.props
103 | const { mentors } = this.state
104 |
105 | if (!user || !user.isAdmin) {
106 | return
107 | }
108 |
109 | const UIwithLayout = withLayout(UI)
110 |
111 | return
112 | }
113 | }
114 |
115 | export default withAuth(Mentors)
116 |
--------------------------------------------------------------------------------
/webApp/pages/rate.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react'
2 | import Error from 'next/error'
3 | import PropTypes from 'prop-types'
4 |
5 | import withLayout from '../lib/withLayout'
6 |
7 | class Rate extends Component {
8 | static propTypes = {
9 | error: PropTypes.string,
10 | mentorName: PropTypes.string
11 | }
12 |
13 | static defaultProps = {
14 | error: null,
15 | mentorName: null
16 | }
17 |
18 | static async getInitialProps({ req, query }) {
19 | if (!req) {
20 | return { error: 'Not found' }
21 | }
22 | const { error, mentorName } = query || {}
23 |
24 | return { error: error ? error.message || error.toString() : null, mentorName }
25 | }
26 |
27 | render() {
28 | const { error, mentorName } = this.props
29 |
30 | if (error === 'Not found') {
31 | return
32 | }
33 |
34 | if (error) {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | {error}
42 |
43 |
44 |
45 | )
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 | Thank you for rating {mentorName}'s advice.
55 |
56 |
57 |
58 | )
59 | }
60 | }
61 |
62 | export default withLayout(Rate, { noHeader: true })
63 |
--------------------------------------------------------------------------------
/webApp/pages/signup.js:
--------------------------------------------------------------------------------
1 | import Head from 'next/head'
2 | import Button from 'material-ui/Button'
3 |
4 | import withAuth from '../lib/withAuth'
5 | import withLayout from '../lib/withLayout'
6 | import { styleLoginButton } from '../components/SharedStyles'
7 |
8 | const Signup = () =>
9 | (
10 |
11 |
Sign up on Harbor
12 |
13 |
14 |
15 |
16 |
17 |
Sign up
18 |
19 | By clicking the button below, you agree to Harbor’s
20 |
25 | {' '}Terms of Service
26 | {' '}
27 | and
28 |
29 | {' '}Privacy Policy
30 |
31 | .
32 |
33 |
34 |
35 |
36 | Sign up with Google
37 |
38 |
39 |
40 |
We do not read your emails or save them to our database.
41 |
)
42 |
43 | export default withAuth(withLayout(Signup), { logoutRequired: true })
44 |
--------------------------------------------------------------------------------
/webApp/server/api.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 |
3 | import User from './models/User'
4 | import Payment from './models/Payment'
5 | import logger from './log'
6 | import { isAccessRevokedError } from './gmail/accessRevoked'
7 | import checkLabelsAndFilters from './gmail/checkLabelsAndFilters'
8 |
9 | const router = express.Router()
10 |
11 | router.use((req, res, next) => {
12 | if (!req.user) {
13 | res.status(401).json({ error: 'Unauthorized access' })
14 | return
15 | }
16 |
17 | next()
18 | })
19 |
20 | router.post('/change-price', (req, res) => {
21 | User.updateOne({ _id: req.user.id }, { price: req.body.price }, { runValidators: true })
22 | .then(raw => res.json(raw))
23 | .catch(err => res.json({ error: err.message || err.toString() }))
24 | })
25 |
26 | router.post('/change-status', (req, res) => {
27 | User.updateOne(
28 | { _id: req.user.id },
29 | { isMentorPagePublic: req.body.isMentorPagePublic },
30 | { runValidators: true }
31 | )
32 | .then(raw => res.json(raw))
33 | .catch(err => res.json({ error: err.message || err.toString() }))
34 | })
35 |
36 | router.post('/change-description', (req, res) => {
37 | User.updateOne({ _id: req.user.id }, { description: req.body.description })
38 | .then(raw => res.json(raw))
39 | .catch(err => res.json({ error: err.message || err.toString() }))
40 | })
41 |
42 | router.get('/get-income-report', (req, res) => {
43 | Payment.getMonthlyIncomeReport({ userId: req.user.id })
44 | .then(incomeList => res.json({ incomeList }))
45 | .catch(err => res.json({ error: err.message || err.toString() }))
46 | })
47 |
48 | router.get('/get-mentor-list', (req, res) => {
49 | if (!req.user.isAdmin) {
50 | res.status(401).json({ error: 'Unauthorized access' })
51 | return
52 | }
53 |
54 | User.getMentorList()
55 | .then(mentors => res.json(mentors))
56 | .catch(err => res.json({ error: err.message || err.toString() }))
57 | })
58 |
59 | router.get('/update-profile', (req, res) => {
60 | User.findById(req.user.id, 'googleToken googleId email')
61 | .then(user => user.updateProfileFromGoogle())
62 | .then(result => res.json(result))
63 | .catch(err => {
64 | const json = { error: err.message || err.toString() }
65 | if (isAccessRevokedError(err)) {
66 | json.isGoogleAccessRevokedError = true
67 | }
68 |
69 | res.json(json)
70 | })
71 | })
72 |
73 | router.get('/check-labels-and-filters', (req, res) => {
74 | const labelFields = User.gmailLabels().map(l => l.fieldName)
75 |
76 | User.findById(req.user.id, `googleToken googleId ${labelFields.join(' ')}`)
77 | .then(user => checkLabelsAndFilters(user))
78 | .then(() => res.json({ checked: true }))
79 | .catch(err => {
80 | logger.error(err)
81 | const json = { error: err.message || err.toString() }
82 | if (isAccessRevokedError(err)) {
83 | json.isGoogleAccessRevokedError = true
84 | }
85 |
86 | res.json(json)
87 | })
88 | })
89 |
90 | export default router
91 |
--------------------------------------------------------------------------------
/webApp/server/app.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import session from 'express-session'
3 | import mongoSessionStore from 'connect-mongo'
4 | import bodyParser from 'body-parser'
5 | import next from 'next'
6 | import mongoose from 'mongoose'
7 |
8 | import Rating from './models/Rating'
9 | import Payment from './models/Payment'
10 | import User from './models/User'
11 | import setupAuth from './auth'
12 | import { setup as setupStripe } from './stripe'
13 | import setupSitemap from './sitemap'
14 | import api from './api'
15 | import publicApi from './public-api'
16 |
17 | require('dotenv').config()
18 |
19 | const dev = process.env.NODE_ENV !== 'production'
20 |
21 | let MONGO_URL = dev ? process.env.MONGO_URL_TEST : process.env.MONGO_URL
22 | MONGO_URL = process.env.USE_MONGO_TEST2 ? process.env.MONGO_URL_TEST2 : MONGO_URL
23 |
24 | mongoose.Promise = global.Promise
25 | mongoose.connect(MONGO_URL, { useMongoClient: true })
26 |
27 | const port = process.env.PORT || 8080
28 | const ROOT_URL = process.env.ROOT_URL || `http://localhost:${port}`
29 |
30 | const app = next({ dev })
31 | const handle = app.getRequestHandler()
32 |
33 | app.prepare().then(() => {
34 | const server = express()
35 |
36 | server.use(bodyParser.json())
37 | server.use(bodyParser.urlencoded({ extended: true }))
38 |
39 | const MongoStore = mongoSessionStore(session)
40 | const sess = {
41 | name: 'findharbor.sid',
42 | secret: '":HD2w.)q*VqRT4/#NK2M/,E^B)}FED5fWU!dKe[wk',
43 | store: new MongoStore({
44 | mongooseConnection: mongoose.connection,
45 | ttl: 14 * 24 * 60 * 60 // save session 14 days
46 | }),
47 | resave: false,
48 | saveUninitialized: false,
49 | cookie: {
50 | httpOnly: true
51 | }
52 | }
53 |
54 | if (!dev) {
55 | server.set('trust proxy', 1) // trust first proxy
56 | sess.cookie.secure = true // serve secure cookies
57 | }
58 |
59 | server.use(session(sess))
60 |
61 | setupAuth({ server, ROOT_URL })
62 | setupStripe({ server })
63 | setupSitemap({ server })
64 |
65 | server.use('/api/v1', api)
66 | server.use('/public-api/v1', publicApi)
67 |
68 | server.get('/contact/:slug', (req, res) => {
69 | const queryParams = { slug: req.params.slug }
70 | app.render(req, res, '/contact', queryParams)
71 | })
72 |
73 | server.get('/checkout/:paymentId', (req, res) => {
74 | Payment.findById(req.params.paymentId, 'chargeFailedCount isCardDeclined userId amount')
75 | .then(payment => {
76 | if (!payment || !payment.isCardDeclined) {
77 | throw new Error('Not found')
78 | }
79 |
80 | User.findById(payment.userId, User.publicFields()).then(mentor =>
81 | app.render(req, res, '/checkout', { payment, mentor })
82 | )
83 | })
84 | .catch(error => {
85 | app.render(req, res, '/checkout', { error })
86 | })
87 | })
88 |
89 | server.get('/rate/yes/:id', (req, res) => {
90 | Rating.rate({ id: req.params.id, recommended: true })
91 | .then(({ mentorName }) => {
92 | app.render(req, res, '/rate', { mentorName })
93 | })
94 | .catch(error => {
95 | app.render(req, res, '/rate', { error })
96 | })
97 | })
98 |
99 | server.get('/rate/no/:id', (req, res) => {
100 | Rating.rate({ id: req.params.id, recommended: false })
101 | .then(({ mentorName }) => {
102 | app.render(req, res, '/rate', { mentorName })
103 | })
104 | .catch(error => {
105 | app.render(req, res, '/rate', { error })
106 | })
107 | })
108 |
109 | server.get('*', (req, res) => handle(req, res))
110 |
111 | server.listen(port, err => {
112 | if (err) throw err
113 | console.log(`> Ready on ${ROOT_URL}`) // eslint-disable-line no-console
114 | })
115 | })
116 |
--------------------------------------------------------------------------------
/webApp/server/auth.js:
--------------------------------------------------------------------------------
1 | import passport from 'passport'
2 | import GoogleStrategy from './gmail/passportStrategy'
3 |
4 | import User from './models/User'
5 |
6 | export default function setup({ ROOT_URL, server }) {
7 | passport.use(
8 | new GoogleStrategy(
9 | {
10 | clientID: process.env.Google_clientID,
11 | clientSecret: process.env.Google_clientSecret,
12 | callbackURL: `${ROOT_URL}/auth/google/callback`
13 | },
14 | (googleToken, profile, cb) => {
15 | let email
16 | let avatarUrl
17 |
18 | if (profile.emails) {
19 | email = profile.emails[0].value
20 | }
21 |
22 | if (profile.image && profile.image.url) {
23 | avatarUrl = profile.image.url.replace('sz=50', 'sz=128')
24 | }
25 |
26 | User.signInOrSignUp({
27 | googleId: profile.id,
28 | email,
29 | googleToken,
30 | displayName: profile.displayName,
31 | avatarUrl
32 | })
33 | .then(user => cb(null, user))
34 | .catch(err => cb(err))
35 | }
36 | )
37 | )
38 |
39 | passport.serializeUser((user, done) => {
40 | done(null, user.id)
41 | })
42 |
43 | passport.deserializeUser((id, done) => {
44 | User.findById(id, User.publicFields(), (err, user) => {
45 | done(err, user)
46 | })
47 | })
48 |
49 | server.use(passport.initialize())
50 | server.use(passport.session())
51 |
52 | server.get('/auth/google', (req, res, next) => {
53 | const options = {
54 | scope: [
55 | 'https://www.googleapis.com/auth/userinfo.profile',
56 | 'https://www.googleapis.com/auth/userinfo.email',
57 | 'https://www.googleapis.com/auth/gmail.settings.basic',
58 | 'https://www.googleapis.com/auth/gmail.modify'
59 | ]
60 | }
61 |
62 | if (req.query && req.query.consent) {
63 | options.prompt = 'consent'
64 | }
65 |
66 | passport.authenticate('google-oauth2', options)(req, res, next)
67 | })
68 |
69 | server.get(
70 | '/auth/google/callback',
71 | passport.authenticate('google-oauth2', {
72 | failureRedirect: '/login'
73 | }),
74 | (req, res) => {
75 | User.findById(req.user.id, 'googleToken')
76 | .then(user => {
77 | if (!user.googleToken || !user.googleToken.refresh_token) {
78 | req.logout()
79 | res.redirect('/login?consent=1')
80 | } else {
81 | req.session.save(() => {
82 | res.redirect('/')
83 | })
84 | }
85 | })
86 | .catch(err => {
87 | console.log(err)
88 | req.logout()
89 | res.redirect('/login')
90 | })
91 | }
92 | )
93 |
94 | server.get('/logout', (req, res) => {
95 | req.logout()
96 | res.redirect('/login')
97 | })
98 | }
99 |
--------------------------------------------------------------------------------
/webApp/server/aws.js:
--------------------------------------------------------------------------------
1 | import aws from 'aws-sdk'
2 |
3 | export function sendEmail(options) {
4 | aws.config.update({
5 | region: 'us-east-1',
6 | accessKeyId: process.env.Amazon_accessKeyId,
7 | secretAccessKey: process.env.Amazon_secretAccessKey
8 | })
9 |
10 | const ses = new aws.SES({ apiVersion: 'latest' })
11 |
12 | return new Promise((resolve, reject) => {
13 | ses.sendEmail(
14 | {
15 | Source: options.from,
16 | Destination: {
17 | CcAddresses: options.cc,
18 | ToAddresses: options.to
19 | },
20 | Message: {
21 | Subject: {
22 | Data: options.subject
23 | },
24 | Body: {
25 | Html: {
26 | Data: options.body
27 | }
28 | }
29 | },
30 | ReplyToAddresses: options.replyTo
31 | },
32 | (err, info) => {
33 | if (err) {
34 | reject(err)
35 | } else {
36 | resolve(info)
37 | }
38 | }
39 | )
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/webApp/server/constants.js:
--------------------------------------------------------------------------------
1 | export const EMAIL_QUESTION_FROM_ADDRESS = 'question@findharbor.com'
2 | export const EMAIL_SUPPORT_FROM_ADDRESS = 'team@findharbor.com'
3 |
--------------------------------------------------------------------------------
/webApp/server/emailTemplates.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import Handlebars from 'handlebars'
3 |
4 | const EmailTemplate = mongoose.model('EmailTemplate', {
5 | name: {
6 | type: String,
7 | required: true,
8 | unique: true
9 | },
10 | subject: {
11 | type: String,
12 | required: true
13 | },
14 | message: {
15 | type: String,
16 | required: true
17 | }
18 | })
19 |
20 | function insertTemplates() {
21 | const templates = [
22 | {
23 | name: 'rating',
24 | subject: "Happy with {{mentorName}}'s advice?",
25 | message: `Hi,
26 |
27 | Based on your email conversation, would you recommend {{mentorName}} to others
28 | (e.g. your friends and family)?
29 |
30 |
31 |
32 | Yes / No
33 |
34 |
35 | Thanks!
36 | Team Harbor`
37 | },
38 | {
39 | name: 'welcome',
40 | subject: 'Welcome to Harbor',
41 | message: `{{mentorName}},
42 |
43 | We're excited that you signed up for Harbor!
44 |
45 |
46 | We're a small, bootstrapped, and remote team.
47 | We built Harbor so that great mentors could give advice and make money in the most
48 | convenient way - by email.
49 |
50 |
51 | To get started, simply connect your Harbor account to Stripe and start sharing your
52 | Harbor page: https://app.findharbor.com/contact/{{mentorSlug}}
53 |
54 |
55 | Advice seekers visit your Harbor page, where they enter their email address,
56 | message, and payment information.
57 | All emails sent with Harbor will appear in your gmail folder called "Harbor"
58 | We verify the payment information and then send the email to your inbox.
59 | The email will be labeled "Card verified".
60 | Once you reply, you'll be paid instantly via Stripe, and the original email will
61 | be re-labeled "Payment successful".
62 | After you reply, the sender will get an email asking if he/she would recommend
63 | you to friends and family.
64 | If you're unable to reply to an email, the sender won't be charged.
65 | If the payment doesn't process successfully, the original email will be
66 | re-labeled "Payment pending".
67 | Harbor receives 10% of transaction (this includes transaction fees from Stripe).
68 | You receive the rest directly in your connected Stripe account.
69 |
70 | Kelly & Timur,
71 | Team Harbor`
72 | },
73 | {
74 | name: 'googleAccessRevoked',
75 | subject: 'Re-login required',
76 | message: `{{mentorName}},
77 |
78 | Harbor's access to your Gmail has expired.
79 | If you wish to continue using Harbor,
80 | go to https://app.findharbor.com/login?consent=1 and log in.
81 |
82 | Team Harbor`
83 | },
84 | {
85 | name: 'tips',
86 | subject: 'How to promote your Harbor page',
87 | message: `{{mentorName}},
88 |
Here's the link for your Harbor page: https://app.findharbor.com/contact/{{mentorSlug}}
89 |
Below are some tips to receive more paid emails via Harbor:
90 |
Write a description for your Harbor page.
91 |
Share your link on social media.
92 |
Add your link to your website, blog, books, etc.
93 |
Email your link to newsletter subscribers or customers.
94 |
Include your link in your signature and auto-reply email.
95 |
96 | Team Harbor`
97 | },
98 | {
99 | name: 'settings',
100 | subject: 'Settings and important links',
101 | message: `
102 |
103 | Your Harbor page: https://app.findharbor.com/contact/{{mentorSlug}}
104 |
105 |
106 | Harbor dashboard: https://app.findharbor.com
107 |
108 |
109 | Stripe dashboard: https://dashboard.stripe.com/dashboard
110 |
111 |
112 | Terms of Service at Harbor: https://www.findharbor.com/terms-of-service
113 |
114 |
115 | Privacy Policy at Harbor: https://www.findharbor.com/privacy-policy
116 |
117 | Team Harbor`
118 | },
119 | {
120 | name: 'chargeFailedToCustomer',
121 | subject: 'Card declined',
122 | message: `
123 |
{{customerEmail}}, we weren't able to process your payment
124 | for {{mentorName}}'s advice.
125 |
It could be that your card expired or didn't have sufficient funds.
126 |
127 | Please go to this link to pay: https://app.findharbor.com/checkout/{{paymentId}}
128 |
129 |
130 | You will not be able to request advice via Harbor until the payment is complete.
131 |
132 | `
133 | }
134 | ]
135 |
136 | templates.forEach(async t => {
137 | if ((await EmailTemplate.find({ name: t.name }).count()) > 0) {
138 | return
139 | }
140 |
141 | EmailTemplate.create(
142 | Object.assign({}, t, { message: t.message.replace(/\n/g, '').replace(/[ ]+/g, ' ') })
143 | ).catch(() => {
144 | // just pass error
145 | })
146 | })
147 | }
148 |
149 | insertTemplates()
150 |
151 | async function getEmail({ name, ...params }) {
152 | const source = await EmailTemplate.findOne({ name })
153 | if (!source) {
154 | throw new Error('not found')
155 | }
156 |
157 | return {
158 | message: Handlebars.compile(source.message)(params),
159 | subject: Handlebars.compile(source.subject)(params)
160 | }
161 | }
162 |
163 | export function chargeFailedToCustomer({ mentorName, paymentId, customerEmail }) {
164 | return getEmail({ name: 'chargeFailedToCustomer', mentorName, paymentId, customerEmail })
165 | }
166 |
167 | export function rating({ mentorName, yesLink, noLink }) {
168 | return getEmail({ name: 'rating', mentorName, yesLink, noLink })
169 | }
170 |
171 | export function welcome({ mentorName, mentorSlug }) {
172 | return getEmail({ name: 'welcome', mentorName, mentorSlug })
173 | }
174 |
175 | export function tips({ mentorName, mentorSlug }) {
176 | return getEmail({ name: 'tips', mentorName, mentorSlug })
177 | }
178 |
179 | export function settings({ mentorName, mentorSlug }) {
180 | return getEmail({ name: 'settings', mentorName, mentorSlug })
181 | }
182 |
183 | export function toMentorGoogleAccessRevoked({ mentorName }) {
184 | return getEmail({ name: 'googleAccessRevoked', mentorName })
185 | }
186 |
--------------------------------------------------------------------------------
/webApp/server/gmail/accessRevoked.js:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from 'mongoose'
2 |
3 | import { EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
4 | import User from '../models/User'
5 | import { sendEmail } from '../aws'
6 | import { toMentorGoogleAccessRevoked } from '../emailTemplates'
7 | import logger from '../log'
8 |
9 | const Session = mongoose.model('Session', new Schema(), 'sessions')
10 |
11 | // when user's google access revoked, we will sent email to user about it
12 | // and log out from all sessions
13 | export default async function accessRevoked(userId) {
14 | if (!userId) {
15 | return null
16 | }
17 |
18 | const user = await User.findById(userId, 'email displayName')
19 | if (!user) {
20 | return null
21 | }
22 |
23 | const emailTemplate = await toMentorGoogleAccessRevoked({ mentorName: user.displayName })
24 |
25 | sendEmail({
26 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
27 | to: [user.email],
28 | cc: [EMAIL_SUPPORT_FROM_ADDRESS],
29 | subject: emailTemplate.subject,
30 | body: emailTemplate.message
31 | })
32 | .then(info => {
33 | logger.info('Email about "access revoked" sent', info)
34 | })
35 | .catch(err => {
36 | logger.error('Email sending error:', err)
37 | })
38 |
39 | const filter = { session: { $regex: `.*"passport":{.*"user":"${userId}".*` } }
40 |
41 | return Session.remove(filter)
42 | }
43 |
44 | export function isAccessRevokedError(err) {
45 | return err.message === 'Invalid Credentials' || err.message === 'invalid_request'
46 | }
47 |
--------------------------------------------------------------------------------
/webApp/server/gmail/api.js:
--------------------------------------------------------------------------------
1 | import omit from 'lodash/omit'
2 | import google from 'googleapis'
3 |
4 | import accessRevoked, { isAccessRevokedError } from './accessRevoked'
5 |
6 | const gmail = google.gmail('v1')
7 | const plus = google.plus('v1')
8 |
9 | const OAuth2 = google.auth.OAuth2
10 |
11 | export function getAuthClient() {
12 | return new OAuth2(
13 | process.env.Google_clientID,
14 | process.env.Google_clientSecret,
15 | 'https://app.findharbor.com/auth/google/callback'
16 | )
17 | }
18 |
19 | function callApi(method, params) {
20 | const { appUserId } = params
21 |
22 | return new Promise((resolve, reject) => {
23 | method(omit(params, ['appUserId']), (err, response) => {
24 | if (err) {
25 | if (isAccessRevokedError(err) && appUserId) {
26 | accessRevoked(appUserId).then(() => reject(err))
27 | } else {
28 | reject(err)
29 | }
30 | } else {
31 | resolve(response)
32 | }
33 | })
34 | })
35 | }
36 |
37 | export function getProfile(params) {
38 | return callApi(plus.people.get, params)
39 | }
40 |
41 | export function messagesList(params) {
42 | return callApi(gmail.users.messages.list, params)
43 | }
44 |
45 | export function getMessage(params) {
46 | return callApi(gmail.users.messages.get, params)
47 | }
48 |
49 | export function modifyMessage(params) {
50 | return callApi(gmail.users.messages.modify, params)
51 | }
52 |
53 | export function getHistoryList(params) {
54 | return callApi(gmail.users.history.list, params)
55 | }
56 |
57 | export function createLabel(params) {
58 | return callApi(gmail.users.labels.create, params)
59 | }
60 |
61 | export function updateLabel(params) {
62 | return callApi(gmail.users.labels.patch, params)
63 | }
64 |
65 | export function getLabelList(params) {
66 | return callApi(gmail.users.labels.list, params)
67 | }
68 |
69 | export function getLabel(params) {
70 | return callApi(gmail.users.labels.get, params)
71 | }
72 |
73 | export function createFilter(params) {
74 | return callApi(gmail.users.settings.filters.create, params)
75 | }
76 |
77 | export function deleteFilter(params) {
78 | return callApi(gmail.users.settings.filters.delete, params)
79 | }
80 |
81 | export function getFilterList(params) {
82 | return callApi(gmail.users.settings.filters.list, params)
83 | }
84 |
--------------------------------------------------------------------------------
/webApp/server/gmail/checkLabelsAndFilters.js:
--------------------------------------------------------------------------------
1 | import User from '../models/User'
2 | import logger from '../log'
3 |
4 | import * as api from './api'
5 | import { isLabelExists, createLabel } from './createLabels'
6 | import createFilter from './createFilter'
7 | import { isAccessRevokedError } from './accessRevoked'
8 |
9 | async function checkLabels(user) {
10 | const oauth2Client = api.getAuthClient()
11 | oauth2Client.setCredentials(user.googleToken)
12 |
13 | const labels = User.gmailLabels()
14 |
15 | for (let j = 0; j < labels.length; j += 1) {
16 | const l = labels[j]
17 |
18 | const labelId = user[l.fieldName]
19 |
20 | try {
21 | // eslint-disable-next-line no-await-in-loop
22 | if (!labelId || !await isLabelExists({ labelId, user, oauth2Client })) {
23 | // eslint-disable-next-line no-await-in-loop
24 | await createLabel({
25 | user,
26 | oauth2Client,
27 | labelName: l.labelName,
28 | fieldName: l.fieldName
29 | })
30 | }
31 | } catch (error) {
32 | if (isAccessRevokedError(error)) {
33 | throw error
34 | } else {
35 | logger.error('Error while checking/creating labels', error)
36 | }
37 |
38 | break
39 | }
40 | }
41 |
42 | try {
43 | await createFilter(user.id)
44 | logger.info('checked filters')
45 | } catch (error) {
46 | if (isAccessRevokedError(error)) {
47 | throw error
48 | } else {
49 | logger.error('Error while checking/creating filters', error)
50 | }
51 | }
52 | }
53 |
54 | export default checkLabels
55 |
--------------------------------------------------------------------------------
/webApp/server/gmail/checkSentEmails.js:
--------------------------------------------------------------------------------
1 | import uniq from 'lodash/uniq'
2 | import uniqBy from 'lodash/uniqBy'
3 | import flatten from 'lodash/flatten'
4 | import addr from 'email-addresses'
5 |
6 | import { isAccessRevokedError } from './accessRevoked'
7 | import User from '../models/User'
8 | import Payment from '../models/Payment'
9 | import { EMAIL_QUESTION_FROM_ADDRESS } from '../constants'
10 | import logger from '../log'
11 | import * as api from './api'
12 |
13 | const TEST_FROM_CLI = !!process.env.TEST_FROM_CLI
14 |
15 | async function getSentEmails({ user, oauth2Client }) {
16 | let sentMessages = []
17 | let historyList
18 | let lastHistoryId
19 |
20 | do {
21 | // eslint-disable-next-line no-await-in-loop
22 | historyList = await api.getHistoryList({
23 | appUserId: user.id,
24 | userId: 'me',
25 | startHistoryId: user.gmailHistoryStartId,
26 | labelId: 'SENT',
27 | maxResults: 1000,
28 | pageToken: (historyList && historyList.nextPageToken) || undefined,
29 | auth: oauth2Client
30 | })
31 |
32 | if (historyList.history) {
33 | sentMessages = sentMessages.concat(historyList.history.map(h => h.messages || []))
34 |
35 | lastHistoryId = historyList.history[historyList.history.length - 1].id
36 | }
37 |
38 | if (historyList.historyId) {
39 | lastHistoryId = historyList.historyId
40 | }
41 | } while (historyList.nextPageToken)
42 |
43 | sentMessages = uniqBy(flatten(sentMessages), 'id')
44 |
45 | return { sentMessages, lastHistoryId }
46 | }
47 |
48 | async function isSentToCustomer({ user, message, oauth2Client }) {
49 | const dev = process.env.NODE_ENV !== 'production'
50 |
51 | const detail = await api.getMessage({
52 | appUserId: user.id,
53 | userId: 'me',
54 | id: message.id,
55 | auth: oauth2Client
56 | })
57 |
58 | // if email did not sent by mentor
59 | if (detail.labelIds.indexOf('SENT') === -1) {
60 | return false
61 | }
62 |
63 | let sentTo
64 |
65 | for (let i = 0; i < detail.payload.headers.length; i += 1) {
66 | const h = detail.payload.headers[i]
67 | if (h.name === 'To') {
68 | sentTo = h.value
69 | break
70 | }
71 | }
72 |
73 | const paymentCount = await Payment.find({
74 | userId: user.id,
75 | 'stripeCustomer.email': addr.parseOneAddress(sentTo).address,
76 | 'email.isSent': true,
77 | 'stripeCustomer.livemode': !dev, // check only test users on dev
78 | isCardDeclined: false,
79 | isCharged: false
80 | }).count()
81 |
82 | return paymentCount > 0
83 | }
84 |
85 | async function checkEmailAndCharge({ message, user, oauth2Client }) {
86 | const dev = process.env.NODE_ENV !== 'production'
87 |
88 | try {
89 | if (!await isSentToCustomer({ message, user, oauth2Client })) {
90 | return
91 | }
92 |
93 | // getting replied email
94 | const detail = await api.getMessage({
95 | appUserId: user.id,
96 | userId: 'me',
97 | id: message.threadId,
98 | auth: oauth2Client
99 | })
100 |
101 | const email = {}
102 |
103 | detail.payload.headers.forEach(h => {
104 | if (['Subject', 'From', 'To', 'Message-ID'].indexOf(h.name) === -1) {
105 | return
106 | }
107 |
108 | email[h.name.toLowerCase()] = h.value
109 | })
110 |
111 | // if not from us, skip it
112 | if (addr.parseOneAddress(email.from).address !== EMAIL_QUESTION_FROM_ADDRESS) {
113 | return
114 | }
115 |
116 | // get original message id (
=> id)
117 | const messageId = addr.parseOneAddress(email['message-id']).local
118 |
119 | const payment = await Payment.findOne(
120 | {
121 | userId: user.id,
122 | 'email.messageId': messageId,
123 | 'email.isSent': true,
124 | 'stripeCustomer.livemode': !dev, // check only test users on dev
125 | isCardDeclined: false,
126 | isCharged: false
127 | },
128 | 'id userId stripeCustomer chargeFailedCount createdAt amount'
129 | )
130 |
131 | if (dev) {
132 | logger.info(detail.id, detail.historyId)
133 | logger.info(email)
134 | logger.info((payment && payment.id) || null)
135 | }
136 |
137 | // payment not found skip
138 | if (!payment) {
139 | return
140 | }
141 |
142 | const now = new Date()
143 | payment.update({ $set: { 'email.gmailId': detail.id, repliedAt: now } }).exec()
144 |
145 | user.calculateAverageResponseTime({ askedAt: payment.createdAt, repliedAt: now })
146 |
147 | try {
148 | await payment.charge({ user })
149 | logger.info('charged')
150 |
151 | api
152 | .modifyMessage({
153 | userId: 'me',
154 | id: detail.id,
155 | resource: {
156 | addLabelIds: [user.gmailPaidLabelId],
157 | removeLabelIds: [user.gmailCardDeclinedLabelId, user.gmailVerifiedLabelId]
158 | },
159 | auth: oauth2Client
160 | })
161 | .then(() => {
162 | logger.info('changed email label')
163 | })
164 | .catch(err2 => logger.error('Error while changing label: ', err2))
165 | } catch (err) {
166 | logger.error('Error while charging: ', err)
167 | api
168 | .modifyMessage({
169 | userId: 'me',
170 | id: detail.id,
171 | resource: {
172 | addLabelIds: [user.gmailCardDeclinedLabelId],
173 | removeLabelIds: [user.gmailPaidLabelId, user.gmailVerifiedLabelId]
174 | },
175 | auth: oauth2Client
176 | })
177 | .catch(err2 => logger.error('Error while changing label: ', err2))
178 | }
179 | } catch (error) {
180 | logger.error(error)
181 | }
182 | }
183 |
184 | async function checkSentEmails() {
185 | const dev = process.env.NODE_ENV !== 'production'
186 | const oauth2Client = api.getAuthClient()
187 |
188 | const userIds = (await Payment.find(
189 | { $or: [{ isCharged: false }, { isCardDeclined: false }] },
190 | 'userId'
191 | )).map(p => p.userId)
192 |
193 | const filter = {
194 | _id: { $in: uniq(userIds) },
195 | isStripeConnected: true,
196 | stripeCustomer: { $exists: true },
197 | 'stripeCustomer.livemode': !dev, // check only test users on dev
198 | gmailHistoryStartId: { $exists: true },
199 | gmailMainLabelId: { $exists: true },
200 | gmailVerifiedLabelId: { $exists: true },
201 | gmailCardDeclinedLabelId: { $exists: true },
202 | gmailPaidLabelId: { $exists: true }
203 | }
204 |
205 | const users = await User.find(
206 | filter,
207 | `googleId displayName email googleToken stripeCustomer price
208 | lastResponseTimes averageResponseTime
209 | gmailHistoryStartId gmailVerifiedLabelId gmailPaidLabelId gmailCardDeclinedLabelId`
210 | )
211 |
212 | logger.info('Mentor count who recieved email: %d', users.length)
213 |
214 | for (let i = 0; i < users.length; i += 1) {
215 | const user = users[i]
216 | let lastHistoryId
217 | let sentMessages
218 |
219 | oauth2Client.setCredentials(user.googleToken)
220 |
221 | try {
222 | // eslint-disable-next-line no-await-in-loop
223 | const sentEmails = await getSentEmails({ user, oauth2Client })
224 |
225 | lastHistoryId = sentEmails.lastHistoryId
226 | sentMessages = sentEmails.sentMessages
227 | } catch (error) {
228 | if (isAccessRevokedError(error)) {
229 | logger.error('Gmail API Error: %s, User: %s', error.message, user.email)
230 | } else {
231 | logger.error(error)
232 | }
233 |
234 | continue // eslint-disable-line no-continue
235 | }
236 |
237 | logger.info('%s(%s) message count %d', user.displayName, user.email, sentMessages.length)
238 |
239 | for (let j = 0; j < sentMessages.length; j += 1) {
240 | const message = sentMessages[j]
241 |
242 | // eslint-disable-next-line no-await-in-loop
243 | await checkEmailAndCharge({ message, user, oauth2Client })
244 | }
245 |
246 | if (lastHistoryId && !TEST_FROM_CLI) {
247 | // eslint-disable-next-line no-await-in-loop
248 | await User.updateOne({ _id: user.id }, { gmailHistoryStartId: lastHistoryId })
249 | }
250 | }
251 | }
252 |
253 | export default checkSentEmails
254 |
255 | if (TEST_FROM_CLI) {
256 | // eslint-disable-next-line global-require
257 | require('dotenv').config()
258 |
259 | // eslint-disable-next-line global-require
260 | const mongoose = require('mongoose')
261 | mongoose.Promise = global.Promise
262 | mongoose.connect(process.env.MONGO_URL_TEST2, { useMongoClient: true })
263 |
264 | checkSentEmails().then(() => mongoose.disconnect())
265 | }
266 |
--------------------------------------------------------------------------------
/webApp/server/gmail/createFilter.js:
--------------------------------------------------------------------------------
1 | import User from '../models/User'
2 | import logger from '../log'
3 | import { EMAIL_QUESTION_FROM_ADDRESS, EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
4 |
5 | import * as api from './api'
6 |
7 | export default async function createFilter(userId) {
8 | const user = await User.findById(
9 | userId,
10 | `googleId googleToken gmailVerifiedLabelId
11 | gmailMainLabelId gmailSettingsLabelId gmailCardDeclinedLabelId`
12 | )
13 |
14 | const oauth2Client = api.getAuthClient()
15 |
16 | oauth2Client.setCredentials(user.googleToken)
17 |
18 | if (user.gmailVerifiedLabelId) {
19 | try {
20 | await api.createFilter({
21 | appUserId: userId,
22 | userId: user.googleId,
23 | resource: {
24 | action: {
25 | addLabelIds: [user.gmailVerifiedLabelId]
26 | },
27 | criteria: {
28 | from: EMAIL_QUESTION_FROM_ADDRESS
29 | }
30 | },
31 | auth: oauth2Client
32 | })
33 | } catch (error) {
34 | if (error.message !== 'Filter already exists') {
35 | logger.error(error)
36 | throw error
37 | }
38 | }
39 | }
40 |
41 | if (user.gmailCardDeclinedLabelId) {
42 | try {
43 | await api.createFilter({
44 | appUserId: userId,
45 | userId: user.googleId,
46 | resource: {
47 | action: {
48 | addLabelIds: [user.gmailCardDeclinedLabelId]
49 | },
50 | criteria: {
51 | from: EMAIL_SUPPORT_FROM_ADDRESS,
52 | subject: 'Card declined'
53 | }
54 | },
55 | auth: oauth2Client
56 | })
57 | } catch (error) {
58 | if (error.message !== 'Filter already exists') {
59 | logger.error(error)
60 | throw error
61 | }
62 | }
63 | }
64 |
65 | if (user.gmailMainLabelId) {
66 | try {
67 | await api.createFilter({
68 | appUserId: userId,
69 | userId: user.googleId,
70 | resource: {
71 | action: {
72 | addLabelIds: [user.gmailMainLabelId]
73 | },
74 | criteria: {
75 | from: EMAIL_QUESTION_FROM_ADDRESS
76 | }
77 | },
78 | auth: oauth2Client
79 | })
80 | } catch (error) {
81 | if (error.message !== 'Filter already exists') {
82 | logger.error(error)
83 | throw error
84 | }
85 | }
86 |
87 | try {
88 | await api.createFilter({
89 | appUserId: userId,
90 | userId: user.googleId,
91 | resource: {
92 | action: {
93 | addLabelIds: [user.gmailMainLabelId]
94 | },
95 | criteria: {
96 | from: EMAIL_SUPPORT_FROM_ADDRESS,
97 | subject: 'Card declined'
98 | }
99 | },
100 | auth: oauth2Client
101 | })
102 | } catch (error) {
103 | if (error.message !== 'Filter already exists') {
104 | logger.error(error)
105 | throw error
106 | }
107 | }
108 |
109 | try {
110 | await api.createFilter({
111 | appUserId: userId,
112 | userId: user.googleId,
113 | resource: {
114 | action: {
115 | addLabelIds: [user.gmailMainLabelId]
116 | },
117 | criteria: {
118 | from: EMAIL_SUPPORT_FROM_ADDRESS
119 | }
120 | },
121 | auth: oauth2Client
122 | })
123 | } catch (error) {
124 | if (error.message !== 'Filter already exists') {
125 | logger.error(error)
126 | throw error
127 | }
128 | }
129 | }
130 |
131 | if (user.gmailSettingsLabelId) {
132 | try {
133 | await api.createFilter({
134 | appUserId: userId,
135 | userId: user.googleId,
136 | resource: {
137 | action: {
138 | addLabelIds: [user.gmailSettingsLabelId]
139 | },
140 | criteria: {
141 | from: EMAIL_SUPPORT_FROM_ADDRESS
142 | }
143 | },
144 | auth: oauth2Client
145 | })
146 | } catch (error) {
147 | if (error.message !== 'Filter already exists') {
148 | logger.error(error)
149 | throw error
150 | }
151 | }
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/webApp/server/gmail/createLabels.js:
--------------------------------------------------------------------------------
1 | import User from '../models/User'
2 | import logger from '../log'
3 |
4 | import * as api from './api'
5 | import { isAccessRevokedError } from './accessRevoked'
6 |
7 | export async function isLabelExists({ user, oauth2Client, labelId }) {
8 | try {
9 | const label = await api.getLabel({
10 | appUserId: user.id,
11 | userId: user.googleId,
12 | id: labelId,
13 | auth: oauth2Client
14 | })
15 |
16 | return !!label && !label.error
17 | } catch (error) {
18 | if (error.message === 'Not Found') {
19 | return false
20 | }
21 |
22 | throw error
23 | }
24 | }
25 |
26 | export async function createLabel({ user, oauth2Client, labelName, fieldName }) {
27 | const modifier = {}
28 | try {
29 | const label = await api.createLabel({
30 | appUserId: user.id,
31 | userId: user.googleId,
32 | resource: { name: labelName },
33 | auth: oauth2Client
34 | })
35 |
36 | modifier[`${fieldName}`] = label.id
37 | } catch (error) {
38 | try {
39 | const response = await api.getLabelList({
40 | appUserId: user.id,
41 | userId: user.googleId,
42 | auth: oauth2Client
43 | })
44 |
45 | if (response && response.labels) {
46 | response.labels.forEach(l => {
47 | if (l.name === labelName) {
48 | modifier[`${fieldName}`] = l.id
49 | }
50 | })
51 | }
52 | } catch (error2) {
53 | logger.error(error.message)
54 | logger.error(error2.message)
55 |
56 | throw error2
57 | }
58 | }
59 |
60 | await User.updateOne({ _id: user.id }, modifier)
61 | }
62 |
63 | export default async function createLabels(userId) {
64 | const labels = User.gmailLabels()
65 | const labelFields = labels.map(l => l.fieldName)
66 |
67 | const user = await User.findById(userId, `googleId googleToken ${labelFields.join(' ')}`)
68 |
69 | const oauth2Client = api.getAuthClient()
70 |
71 | oauth2Client.setCredentials(user.googleToken)
72 |
73 | for (let i = 0; i < labels.length; i += 1) {
74 | const l = labels[i]
75 |
76 | const labelId = user[l.fieldName]
77 |
78 | try {
79 | // eslint-disable-next-line no-await-in-loop
80 | if (!labelId || !await isLabelExists({ labelId, user, oauth2Client })) {
81 | // eslint-disable-next-line no-await-in-loop
82 | await createLabel({
83 | user,
84 | oauth2Client,
85 | labelName: l.labelName,
86 | fieldName: l.fieldName
87 | })
88 | }
89 | } catch (error) {
90 | logger.error('Error while checking/creating label', l, error)
91 | if (isAccessRevokedError(error)) {
92 | throw error
93 | }
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/webApp/server/gmail/index.js:
--------------------------------------------------------------------------------
1 | import setHistoryStartId from './setHistoryStartId'
2 | import createLabels from './createLabels'
3 | import createFilter from './createFilter'
4 |
5 | export function setup(userId) {
6 | setHistoryStartId(userId)
7 |
8 | return createLabels(userId).then(() => createFilter(userId))
9 | }
10 |
--------------------------------------------------------------------------------
/webApp/server/gmail/migrate.js:
--------------------------------------------------------------------------------
1 | import User from '../models/User'
2 | import * as api from './api'
3 | import { EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
4 |
5 | async function migrateLabel() {
6 | const oauth2Client = api.getAuthClient()
7 |
8 | const users = await User.find({}, 'googleId displayName email googleToken gmailSettingsLabelId')
9 |
10 | for (let i = 0; i < users.length; i += 1) {
11 | const user = users[i]
12 | console.log(user.displayName, user.gmailSettingsLabelId)
13 |
14 | oauth2Client.setCredentials(user.googleToken)
15 |
16 | try {
17 | await api.updateLabel({
18 | userId: user.googleId,
19 | id: user.gmailSettingsLabelId,
20 | resource: { name: 'Harbor/Team Harbor' },
21 | auth: oauth2Client
22 | })
23 | } catch (err) {
24 | console.log(err)
25 | }
26 | }
27 | }
28 |
29 | async function migrateFilter() {
30 | const oauth2Client = api.getAuthClient()
31 |
32 | const users = await User.find(
33 | {},
34 | 'googleId displayName email googleToken gmailSettingsLabelId gmailMainLabelId'
35 | )
36 |
37 | for (let i = 0; i < users.length; i += 1) {
38 | const user = users[i]
39 | console.log(user.displayName, user.gmailSettingsLabelId)
40 |
41 | oauth2Client.setCredentials(user.googleToken)
42 |
43 | let filters = []
44 | try {
45 | filters =
46 | (await api.getFilterList({
47 | userId: user.googleId,
48 | auth: oauth2Client
49 | })).filter || []
50 | } catch (err) {
51 | console.log(err)
52 | }
53 |
54 | for (let j = 0; j < filters.length; j += 1) {
55 | const filter = filters[j]
56 | const { criteria = {} } = filter
57 |
58 | if (
59 | criteria.from === EMAIL_SUPPORT_FROM_ADDRESS &&
60 | criteria.subject === 'Settings OR Welcome OR Update'
61 | ) {
62 | console.log(filter.id, 'deleting', criteria)
63 | await api.deleteFilter({
64 | id: filter.id,
65 | userId: user.googleId,
66 | auth: oauth2Client
67 | })
68 | }
69 | }
70 |
71 | if (user.gmailSettingsLabelId) {
72 | try {
73 | await api.createFilter({
74 | userId: user.googleId,
75 | resource: {
76 | action: {
77 | addLabelIds: [user.gmailSettingsLabelId]
78 | },
79 | criteria: {
80 | from: EMAIL_SUPPORT_FROM_ADDRESS
81 | }
82 | },
83 | auth: oauth2Client
84 | })
85 | } catch (error) {
86 | console.log(error.message)
87 | }
88 | }
89 |
90 | if (user.gmailMainLabelId) {
91 | try {
92 | await api.createFilter({
93 | userId: user.googleId,
94 | resource: {
95 | action: {
96 | addLabelIds: [user.gmailMainLabelId]
97 | },
98 | criteria: {
99 | from: EMAIL_SUPPORT_FROM_ADDRESS
100 | }
101 | },
102 | auth: oauth2Client
103 | })
104 | } catch (error) {
105 | console.log(error.message)
106 | }
107 | }
108 | }
109 | }
110 |
111 | function init() {
112 | // eslint-disable-next-line global-require
113 | require('dotenv').config()
114 |
115 | // eslint-disable-next-line global-require
116 | const mongoose = require('mongoose')
117 | mongoose.Promise = global.Promise
118 | // mongoose.connect(process.env.MONGO_URL_TEST2, { useMongoClient: true })
119 | // mongoose.connect(process.env.MONGO_URL_TEST, { useMongoClient: true })
120 | // mongoose.connect(process.env.MONGO_URL, { useMongoClient: true })
121 |
122 | // migrateLabel().then(() => mongoose.disconnect()).catch(err => {
123 | // console.log(err)
124 | // mongoose.disconnect()
125 | // })
126 |
127 | // migrateFilter().then(() => mongoose.disconnect()).catch(err => {
128 | // console.log(err)
129 | // mongoose.disconnect()
130 | // })
131 | }
132 |
133 | init()
134 |
--------------------------------------------------------------------------------
/webApp/server/gmail/passportStrategy.js:
--------------------------------------------------------------------------------
1 | import util from 'util'
2 | import passport from 'passport-strategy'
3 | import google from 'googleapis'
4 |
5 | function Strategy({ clientID, clientSecret, callbackURL }, verify) {
6 | if (!verify) {
7 | throw new TypeError('Strategy requires a verify callback')
8 | }
9 |
10 | if (!clientID) {
11 | throw new TypeError('Strategy requires a clientID option')
12 | }
13 |
14 | if (!clientSecret) {
15 | throw new TypeError('Strategy requires a clientSecret option')
16 | }
17 |
18 | if (!callbackURL) {
19 | throw new TypeError('Strategy requires a callbackURL option')
20 | }
21 |
22 | passport.Strategy.call(this)
23 | this.name = 'google-oauth2'
24 | this.verify = verify
25 |
26 | const OAuth2 = google.auth.OAuth2
27 | this.oauth2Client = new OAuth2(clientID, clientSecret, callbackURL)
28 | }
29 |
30 | // Inherit from `passport.Strategy`.
31 | util.inherits(Strategy, passport.Strategy)
32 |
33 | Strategy.prototype.authenticate = function authenticate(req, options) {
34 | if (req.query && req.query.error) {
35 | if (req.query.error === 'access_denied') {
36 | this.fail({ message: req.query.error_description })
37 | return
38 | }
39 |
40 | this.error(new Error(req.query.error_description, req.query.error, req.query.error_uri))
41 | return
42 | }
43 |
44 | if (req.query && req.query.code) {
45 | this.oauth2Client.getToken(req.query.code, (err, token) => {
46 | if (err) {
47 | this.error(err)
48 | return
49 | }
50 |
51 | this.oauth2Client.setCredentials(token)
52 | this.loadUserProfile((err2, profile) => {
53 | if (err2) {
54 | this.error(err2)
55 | return
56 | }
57 |
58 | const verified = (err3, user, info) => {
59 | if (err3) {
60 | this.error(err3)
61 | return
62 | }
63 |
64 | if (!user) {
65 | this.fail(info)
66 | return
67 | }
68 |
69 | this.success(user, info || {})
70 | }
71 |
72 | this.verify(token, profile, verified)
73 | })
74 | })
75 | } else {
76 | const authUrl = this.oauth2Client.generateAuthUrl(
77 | Object.assign(
78 | {},
79 | {
80 | access_type: 'offline'
81 | },
82 | options
83 | )
84 | )
85 | this.redirect(authUrl)
86 | }
87 | }
88 |
89 | Strategy.prototype.loadUserProfile = function loadUserProfile(done) {
90 | const plus = google.plus('v1')
91 |
92 | plus.people.get(
93 | {
94 | userId: 'me',
95 | auth: this.oauth2Client
96 | },
97 | (err, resp) => {
98 | if (err) {
99 | done(err)
100 | return
101 | }
102 |
103 | const profile = resp
104 | profile.provider = 'google'
105 |
106 | done(null, profile)
107 | }
108 | )
109 | }
110 |
111 | module.exports = Strategy
112 |
--------------------------------------------------------------------------------
/webApp/server/gmail/setHistoryStartId.js:
--------------------------------------------------------------------------------
1 | import User from '../models/User'
2 |
3 | import * as api from './api'
4 | import logger from '../log'
5 |
6 | export default async function setHistoryStartId(userId) {
7 | const oauth2Client = api.getAuthClient()
8 | const user = await User.findById(userId, 'googleId googleToken gmailHistoryStartId')
9 |
10 | if (user.gmailHistoryStartId) {
11 | return
12 | }
13 |
14 | oauth2Client.setCredentials(user.googleToken)
15 |
16 | try {
17 | const list = await api.messagesList({
18 | userId: user.googleId,
19 | maxResults: 1,
20 | auth: oauth2Client
21 | })
22 |
23 | if (list && list.messages && list.messages[0]) {
24 | const lastMessage = await api.getMessage({
25 | userId: user.googleId,
26 | id: list.messages[0].id,
27 | auth: oauth2Client
28 | })
29 |
30 | if (lastMessage && lastMessage.historyId) {
31 | User.updateOne({ _id: userId }, { gmailHistoryStartId: lastMessage.historyId }).exec()
32 | }
33 | }
34 | } catch (error) {
35 | logger.error(error)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/webApp/server/log.js:
--------------------------------------------------------------------------------
1 | import winston from 'winston'
2 |
3 | export default winston
4 |
--------------------------------------------------------------------------------
/webApp/server/models/BlackList.js:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from 'mongoose'
2 |
3 | export default mongoose.model('BlackList', {
4 | email: {
5 | type: String,
6 | required: true,
7 | unique: true
8 | },
9 | notPaidPaymentId: {
10 | type: Schema.Types.ObjectId,
11 | required: true
12 | }
13 | })
14 |
--------------------------------------------------------------------------------
/webApp/server/models/Invitation.js:
--------------------------------------------------------------------------------
1 | /*
2 | import mongoose from 'mongoose'
3 |
4 | export default mongoose.model('Invitation', {
5 | email: {
6 | type: String,
7 | required: true,
8 | unique: true
9 | }
10 | })
11 | */
12 |
--------------------------------------------------------------------------------
/webApp/server/models/Payment.js:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from 'mongoose'
2 | import moment from 'moment'
3 | import addrs from 'email-addresses'
4 |
5 | import Rating from './Rating'
6 | import BlackList from './BlackList'
7 | import User from './User'
8 | import { EMAIL_QUESTION_FROM_ADDRESS, EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
9 | import { sendEmail } from '../aws'
10 | import { chargeFailedToCustomer } from '../emailTemplates'
11 | import { createCustomer, charge as stripeCharge } from '../stripe'
12 | import logger from '../log'
13 | import * as gmailApi from '../gmail/api'
14 |
15 | const validateEmail = email => !!addrs(email)
16 |
17 | const schema = new Schema({
18 | userId: {
19 | type: Schema.Types.ObjectId,
20 | required: true
21 | },
22 | createdAt: {
23 | type: Date,
24 | required: true
25 | },
26 | repliedAt: {
27 | type: Date
28 | },
29 | isRatingEmailSent: {
30 | type: Boolean,
31 | default: false,
32 | required: true
33 | },
34 |
35 | amount: {
36 | type: Number,
37 | required: true
38 | },
39 | chargedAt: {
40 | type: Date
41 | },
42 | isCharged: {
43 | type: Boolean,
44 | default: false,
45 | required: true
46 | },
47 | chargeFailedAt: {
48 | type: Date
49 | },
50 | chargeFailedCount: {
51 | type: Number,
52 | default: 0,
53 | required: true
54 | },
55 | isCardDeclined: {
56 | type: Boolean,
57 | default: false,
58 | required: true
59 | },
60 |
61 | stripeCustomer: {
62 | card: {
63 | brand: String,
64 | exp_month: Number,
65 | exp_year: Number,
66 | id: String,
67 | last4: String
68 | },
69 | email: {
70 | type: String,
71 | required: true,
72 | trim: true,
73 | lowercase: true,
74 | validate: [validateEmail, 'Please fill a valid email address']
75 | },
76 | id: {
77 | type: String,
78 | required: true
79 | },
80 | livemode: {
81 | type: Boolean,
82 | required: true
83 | }
84 | },
85 | stripeCharge: {
86 | id: String,
87 | amount: Number,
88 | created: Number,
89 | livemode: Boolean,
90 | paid: Boolean,
91 | application_fee: String,
92 | status: String
93 | },
94 | email: {
95 | subject: {
96 | type: String,
97 | required: true
98 | },
99 | message: {
100 | type: String,
101 | required: true
102 | },
103 | isSent: {
104 | type: Boolean,
105 | default: false,
106 | required: true
107 | },
108 | gmailId: String,
109 | messageId: String // 'Message-ID' from Amazon SES
110 | }
111 | })
112 |
113 | class PaymentClass {
114 | static async getMonthlyIncomeReport({ userId }) {
115 | const user = await User.findById(userId, 'id createdAt')
116 | const incomeList = {}
117 |
118 | if (!user) {
119 | return Promise.reject(new Error('User not found'))
120 | }
121 |
122 | const userRegisteredMonth = moment(user.createdAt).format('YYYYMM')
123 | const month = moment()
124 |
125 | while (month.format('YYYYMM') !== userRegisteredMonth) {
126 | incomeList[month.format('MMMM, YYYY')] = 0
127 | month.subtract(1, 'months')
128 | }
129 |
130 | incomeList[moment(user.createdAt).format('MMMM, YYYY')] = 0
131 |
132 | const payments = await this.find(
133 | { userId, isCharged: true, stripeCharge: { $exists: true } },
134 | 'stripeCharge chargedAt createdAt'
135 | )
136 |
137 | payments.forEach(payment => {
138 | const m = moment(payment.chargedAt || payment.createdAt).format('MMMM, YYYY')
139 |
140 | incomeList[m] = payment.stripeCharge.amount * 0.9 + (incomeList[m] || 0)
141 | })
142 |
143 | return Object.keys(incomeList).map(k => ({ month: k, income: incomeList[k] / 100 }))
144 | }
145 |
146 | static async createNewPayment({ userId, stripeToken, email }) {
147 | const user = await User.findById(userId, 'id email displayName price')
148 | if (!user) {
149 | return Promise.reject(new Error('User not found'))
150 | }
151 |
152 | const blackList = await BlackList.findOne({
153 | email: stripeToken.email
154 | })
155 | if (blackList) {
156 | return Promise.reject(
157 | new Error(
158 | `You (${stripeToken.email}) can't request advice on Harbor due to unpaid email advice.
159 | Click here to pay `
160 | )
161 | )
162 | }
163 |
164 | const createdAt = new Date()
165 |
166 | const customer = await createCustomer({ token: stripeToken.id, email: stripeToken.email })
167 | customer.card = stripeToken.card
168 | const payment = await this.create({
169 | userId,
170 | createdAt,
171 | stripeCustomer: customer,
172 | email,
173 | amount: user.price
174 | })
175 |
176 | sendEmail({
177 | from: `Harbor <${EMAIL_QUESTION_FROM_ADDRESS}>`,
178 | to: [user.email],
179 | cc: [stripeToken.email],
180 | subject: email.subject,
181 | body: `${email.message.replace(/\n/g, ' ')}
182 | Note to ${user.displayName} (${user.email}):
183 | You'll be paid for replying to this email .
184 | If you're not able to reply to this email, the sender won't be charged.
185 | After receiving your reply, the sender's card will be automatically charged
186 | and the sender will be asked to rate your reply. `,
187 | replyTo: [stripeToken.email]
188 | })
189 | .then(info => {
190 | logger.info('Email sent', info)
191 |
192 | this.updateOne(
193 | { _id: payment.id },
194 | { $set: { 'email.isSent': true, 'email.messageId': info.MessageId } }
195 | ).exec()
196 | })
197 | .catch(err => {
198 | logger.error('Email sending error:', err)
199 | })
200 |
201 | return payment
202 | }
203 |
204 | async charge({ user }) {
205 | try {
206 | const chargeObj = await stripeCharge({
207 | customerId: this.stripeCustomer.id,
208 | account: user.stripeCustomer.stripe_user_id,
209 | mentorName: user.displayName,
210 | mentorEmail: user.email,
211 | customerEmail: this.stripeCustomer.email,
212 | amount: this.amount * 100
213 | })
214 |
215 | await this.update({
216 | $set: {
217 | isCharged: true,
218 | isCardDeclined: false,
219 | chargedAt: new Date(),
220 | stripeCharge: chargeObj
221 | },
222 | $unset: {
223 | chargeFailedAt: 1
224 | }
225 | })
226 |
227 | Rating.createNewRating({ payment: this })
228 | .then(() => {
229 | logger.info('rating email sent')
230 | this.update({ $set: { isRatingEmailSent: true } }).exec()
231 | })
232 | .catch(err => {
233 | logger.error('Error while creating rating: ', err)
234 | })
235 |
236 | return chargeObj
237 | } catch (error) {
238 | await this.update({
239 | $set: { isCardDeclined: true, chargeFailedAt: new Date() },
240 | $inc: { chargeFailedCount: 1 }
241 | })
242 |
243 | const emailToCustomer = await chargeFailedToCustomer({
244 | customerEmail: this.stripeCustomer.email,
245 | mentorName: user.displayName,
246 | paymentId: this.id
247 | })
248 |
249 | await BlackList.findOneAndUpdate(
250 | { email: this.stripeCustomer.email },
251 | { email: this.stripeCustomer.email, notPaidPaymentId: this.id },
252 | { upsert: true }
253 | )
254 |
255 | sendEmail({
256 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
257 | to: [this.stripeCustomer.email],
258 | cc: [user.email],
259 | subject: emailToCustomer.subject,
260 | body: emailToCustomer.message
261 | }).catch(err => {
262 | logger.error('Email sending error:', err)
263 | })
264 |
265 | throw error
266 | }
267 | }
268 |
269 | async chargeDebt({ stripeToken }) {
270 | try {
271 | const user = await User.findById(
272 | this.userId,
273 | `displayName email stripeCustomer googleToken googleId
274 | gmailPaidLabelId gmailCardDeclinedLabelId gmailVerifiedLabelId`
275 | )
276 | const customer = await createCustomer({ token: stripeToken.id, email: stripeToken.email })
277 |
278 | const chargeObj = await stripeCharge({
279 | customerId: customer.id,
280 | account: user.stripeCustomer.stripe_user_id,
281 | mentorName: user.displayName,
282 | mentorEmail: user.email,
283 | customerEmail: stripeToken.email,
284 | amount: this.amount * 100
285 | })
286 |
287 | await this.update({
288 | $set: {
289 | isCharged: true,
290 | isCardDeclined: false,
291 | chargedAt: new Date(),
292 | stripeCharge: chargeObj
293 | },
294 | $unset: {
295 | chargeFailedAt: 1
296 | }
297 | })
298 |
299 | this.stripeCustomer = customer
300 | Rating.createNewRating({ payment: this })
301 | .then(() => {
302 | logger.info('rating email sent')
303 | this.update({ $set: { isRatingEmailSent: true } }).exec()
304 | })
305 | .catch(err => {
306 | logger.error('Error while creating rating: ', err)
307 | })
308 |
309 | await BlackList.remove({ email: stripeToken.email })
310 |
311 | const oauth2Client = gmailApi.getAuthClient()
312 | oauth2Client.setCredentials(user.googleToken)
313 |
314 | gmailApi
315 | .modifyMessage({
316 | userId: user.googleId,
317 | id: this.email.gmailId,
318 | resource: {
319 | addLabelIds: [user.gmailPaidLabelId],
320 | removeLabelIds: [user.gmailCardDeclinedLabelId, user.gmailVerifiedLabelId]
321 | },
322 | auth: oauth2Client
323 | })
324 | .then(() => {
325 | logger.info('changed email label')
326 | })
327 | .catch(err2 => logger.error('Error while changing label: ', err2))
328 |
329 | return chargeObj
330 | } catch (error) {
331 | logger.error('Error while charging debt', error)
332 | throw error
333 | }
334 | }
335 | }
336 |
337 | schema.loadClass(PaymentClass)
338 |
339 | const Payment = mongoose.model('Payment', schema)
340 |
341 | export default Payment
342 |
--------------------------------------------------------------------------------
/webApp/server/models/Rating.js:
--------------------------------------------------------------------------------
1 | import mongoose, { Schema } from 'mongoose'
2 |
3 | import { EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
4 | import { sendEmail } from '../aws'
5 | import { rating as getRatingEmail } from '../emailTemplates'
6 | import logger from '../log'
7 | import getRootURL from '../../lib/getRootURL'
8 |
9 | import User from './User'
10 |
11 | const schema = new Schema({
12 | mentorId: {
13 | type: Schema.Types.ObjectId,
14 | required: true
15 | },
16 | paymentId: {
17 | type: Schema.Types.ObjectId,
18 | required: true
19 | },
20 | createdAt: {
21 | type: Date,
22 | required: true
23 | },
24 | clicked: {
25 | type: Boolean,
26 | default: false,
27 | required: true
28 | },
29 | recommended: {
30 | type: Boolean
31 | }
32 | })
33 |
34 | class RatingClass {
35 | static async rate({ id, recommended }) {
36 | const rate = await this.findById(id, 'id mentorId clicked')
37 | if (!rate) {
38 | return Promise.reject(new Error('Not found'))
39 | }
40 |
41 | const mentor = await User.findById(rate.mentorId, 'id displayName')
42 | if (!mentor) {
43 | return Promise.reject(new Error('Not found'))
44 | }
45 |
46 | if (rate.clicked) {
47 | return Promise.reject(new Error('You already rated this advice.'))
48 | }
49 |
50 | await rate.update({ clicked: true, recommended })
51 | await mentor.update({
52 | $inc: {
53 | 'rating.totalCount': 1,
54 | 'rating.recommendCount': recommended ? 1 : 0,
55 | 'rating.notRecommendCount': recommended ? 0 : 1
56 | }
57 | })
58 |
59 | return { mentorName: mentor.displayName }
60 | }
61 |
62 | static async createNewRating({ payment }) {
63 | if (!payment) {
64 | return Promise.reject(new Error('Payment is required'))
65 | }
66 |
67 | const user = await User.findById(payment.userId, 'id displayName')
68 | if (!user) {
69 | return Promise.reject(new Error('User not found'))
70 | }
71 |
72 | const createdAt = new Date()
73 | const rating = await this.create({
74 | mentorId: payment.userId,
75 | paymentId: payment.id,
76 | createdAt
77 | })
78 |
79 | const urls = rating.getRatingURLs()
80 | const email = await getRatingEmail({
81 | mentorName: user.displayName,
82 | yesLink: urls.yes,
83 | noLink: urls.no,
84 | mentorSlug: user.slug
85 | })
86 |
87 | try {
88 | const info = await sendEmail({
89 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
90 | to: [payment.stripeCustomer.email],
91 | subject: email.subject,
92 | body: email.message
93 | })
94 | logger.info('Rating email sent', info)
95 | } catch (error) {
96 | logger.error('Email sending error:', error)
97 |
98 | await this.remove({ _id: rating.id })
99 | throw error
100 | }
101 |
102 | return rating
103 | }
104 |
105 | getRatingURLs() {
106 | const rootURL = getRootURL()
107 |
108 | return {
109 | yes: `${rootURL}/rate/yes/${this.id}`,
110 | no: `${rootURL}/rate/no/${this.id}`
111 | }
112 | }
113 | }
114 |
115 | schema.loadClass(RatingClass)
116 |
117 | const Rating = mongoose.model('Rating', schema)
118 |
119 | export default Rating
120 |
--------------------------------------------------------------------------------
/webApp/server/models/User.js:
--------------------------------------------------------------------------------
1 | import isEmpty from 'lodash/isEmpty'
2 | import mean from 'lodash/mean'
3 | import pick from 'lodash/pick'
4 |
5 | import mongoose, { Schema } from 'mongoose'
6 |
7 | // import Invitation from './Invitation'
8 | import { EMAIL_SUPPORT_FROM_ADDRESS } from '../constants'
9 | import generateSlug from '../utils/slugify'
10 | import { setup } from '../gmail'
11 | import { sendEmail } from '../aws'
12 | import {
13 | welcome as getWelcomeEmail,
14 | settings as getSettingsEmail,
15 | tips as getTipsEmail
16 | } from '../emailTemplates'
17 | import logger from '../log'
18 | import * as gmailApi from '../gmail/api'
19 |
20 | const schema = new Schema({
21 | googleId: {
22 | type: String,
23 | required: true,
24 | unique: true
25 | },
26 | googleToken: {
27 | access_token: String,
28 | refresh_token: String,
29 | token_type: String,
30 | expiry_date: Number
31 | },
32 | slug: {
33 | type: String,
34 | required: true,
35 | unique: true
36 | },
37 | createdAt: {
38 | type: Date,
39 | required: true
40 | },
41 | email: {
42 | type: String,
43 | required: true,
44 | unique: true
45 | },
46 | displayName: String,
47 | description: {
48 | type: String,
49 | default: ''
50 | },
51 | avatarUrl: String,
52 |
53 | lastResponseTimes: [Number], // last 10 response times in minutes
54 | averageResponseTime: {
55 | default: 1,
56 | required: true,
57 | type: Number // in hours
58 | },
59 |
60 | repliedCount: {
61 | type: Number,
62 | default: 0
63 | },
64 | rating: {
65 | totalCount: {
66 | type: Number,
67 | default: 0
68 | },
69 | recommendCount: {
70 | type: Number,
71 | default: 0
72 | },
73 | notRecommendCount: {
74 | type: Number,
75 | default: 0
76 | }
77 | },
78 | links: [
79 | {
80 | url: {
81 | type: String,
82 | required: true
83 | },
84 | title: {
85 | type: String,
86 | required: true
87 | }
88 | }
89 | ],
90 |
91 | price: {
92 | type: Number,
93 | required: true,
94 | default: 50,
95 | validate: {
96 | validator(v) {
97 | return [25, 50, 100].includes(v)
98 | },
99 | message: '{VALUE} is not a valid price!'
100 | }
101 | },
102 |
103 | isMentorPagePublic: {
104 | type: Boolean,
105 | required: true,
106 | default: true
107 | },
108 |
109 | isStripeConnected: Boolean,
110 | stripeCustomer: {
111 | token_type: String,
112 | stripe_publishable_key: String,
113 | scope: String,
114 | livemode: Boolean,
115 | stripe_user_id: String,
116 | refresh_token: String,
117 | access_token: String
118 | },
119 |
120 | gmailHistoryStartId: String,
121 | gmailMainLabelId: String,
122 | gmailVerifiedLabelId: String,
123 | gmailPaidLabelId: String,
124 | gmailSettingsLabelId: String,
125 | gmailCardDeclinedLabelId: String,
126 |
127 | isAdmin: {
128 | type: Boolean,
129 | default: false
130 | }
131 | })
132 |
133 | class UserClass {
134 | static publicFields() {
135 | return [
136 | 'id',
137 | 'displayName',
138 | 'email',
139 | 'avatarUrl',
140 | 'slug',
141 | 'description',
142 | 'links',
143 | 'price',
144 | 'isStripeConnected',
145 | 'isMentorPagePublic',
146 | 'averageResponseTime',
147 | 'repliedCount',
148 | 'rating',
149 | 'isAdmin'
150 | ]
151 | }
152 |
153 | static gmailLabels() {
154 | return [
155 | { fieldName: 'gmailMainLabelId', labelName: 'Harbor' },
156 | { fieldName: 'gmailVerifiedLabelId', labelName: 'Harbor/Card verified' },
157 | { fieldName: 'gmailPaidLabelId', labelName: 'Harbor/Payment successful' },
158 | { fieldName: 'gmailCardDeclinedLabelId', labelName: 'Harbor/Payment pending' },
159 | { fieldName: 'gmailSettingsLabelId', labelName: 'Harbor/Team Harbor' }
160 | ]
161 | }
162 |
163 | static signInOrSignUp({ googleId, email, googleToken, displayName, avatarUrl }) {
164 | return this.findOne({ googleId }, UserClass.publicFields().join(' ')).then(user => {
165 | if (user) {
166 | const modifier = {}
167 | Object.keys(googleToken || {}).forEach(k => {
168 | if (googleToken[k]) {
169 | modifier[`googleToken.${k}`] = googleToken[k]
170 | }
171 | })
172 |
173 | if (isEmpty(modifier)) {
174 | setup(user.id)
175 | return Promise.resolve(user)
176 | }
177 |
178 | return this.updateOne({ googleId }, { $set: modifier }).then(() => {
179 | setup(user.id)
180 | return Promise.resolve(user)
181 | })
182 | }
183 |
184 | return generateSlug(this, displayName).then(slug =>
185 | this.create({
186 | createdAt: new Date(),
187 | googleId,
188 | email,
189 | googleToken,
190 | displayName,
191 | avatarUrl,
192 | slug
193 | }).then(newUser => {
194 | setup(newUser.id).then(() => {
195 | getWelcomeEmail({
196 | mentorName: displayName,
197 | mentorSlug: slug
198 | })
199 | .then(template =>
200 | sendEmail({
201 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
202 | to: [email],
203 | subject: template.subject,
204 | body: template.message
205 | })
206 | )
207 | .catch(error => {
208 | logger.error('Email sending error:', error)
209 | })
210 |
211 | getTipsEmail({
212 | mentorName: displayName,
213 | mentorSlug: slug
214 | })
215 | .then(template =>
216 | sendEmail({
217 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
218 | to: [email],
219 | subject: template.subject,
220 | body: template.message
221 | })
222 | )
223 | .catch(error => {
224 | logger.error('Email sending error:', error)
225 | })
226 |
227 | getSettingsEmail({
228 | mentorName: displayName,
229 | mentorSlug: slug
230 | })
231 | .then(template =>
232 | sendEmail({
233 | from: `Harbor <${EMAIL_SUPPORT_FROM_ADDRESS}>`,
234 | to: [email],
235 | subject: template.subject,
236 | body: template.message
237 | })
238 | )
239 | .catch(error => {
240 | logger.error('Email sending error:', error)
241 | })
242 | })
243 |
244 | return Promise.resolve(pick(newUser, UserClass.publicFields()))
245 | })
246 | )
247 |
248 | // return Invitation.findOne({ email }).then(invitation => {
249 | // if (!invitation) {
250 | // return Promise.resolve(false)
251 | // }
252 |
253 | // return generateSlug(this, displayName).then(slug =>
254 | // this.create({
255 | // googleId,
256 | // email,
257 | // googleToken,
258 | // displayName,
259 | // avatarUrl,
260 | // slug
261 | // }).then(newUser => {
262 | // setup(newUser.id)
263 | // return Promise.resolve(pick(newUser, UserClass.publicFields()))
264 | // })
265 | // )
266 | // })
267 | })
268 | }
269 |
270 | static getMentorList() {
271 | return this.find({}, UserClass.publicFields().join(' ')).sort({ createdAt: -1 })
272 | }
273 |
274 | async updateProfileFromGoogle() {
275 | const oauth2Client = gmailApi.getAuthClient()
276 | oauth2Client.setCredentials(this.googleToken)
277 |
278 | let profile
279 | try {
280 | profile = await gmailApi.getProfile({
281 | userId: 'me',
282 | auth: oauth2Client,
283 | appUserId: this.id
284 | })
285 | } catch (err) {
286 | logger.error('Error while getting profile: ', err)
287 | throw err
288 | }
289 |
290 | const modifier = { displayName: profile.displayName }
291 |
292 | if (profile.image && profile.image.url) {
293 | modifier.avatarUrl = profile.image.url.replace('sz=50', 'sz=128')
294 | }
295 |
296 | await this.update({ $set: modifier })
297 |
298 | return modifier
299 | }
300 |
301 | calculateAverageResponseTime({ askedAt, repliedAt }) {
302 | const responseTimeInMinutes = Math.round(
303 | (repliedAt.getTime() - askedAt.getTime()) / (60 * 1000)
304 | )
305 |
306 | const { lastResponseTimes = [] } = this
307 | lastResponseTimes.push(responseTimeInMinutes)
308 |
309 | if (lastResponseTimes.length > 10) {
310 | lastResponseTimes.shift()
311 | }
312 |
313 | this.update({
314 | $inc: {
315 | repliedCount: 1
316 | },
317 | $set: {
318 | lastResponseTimes,
319 | averageResponseTime: Math.round(mean(lastResponseTimes) / 60) || 1
320 | }
321 | }).exec()
322 | }
323 | }
324 |
325 | schema.loadClass(UserClass)
326 |
327 | const User = mongoose.model('User', schema)
328 |
329 | export default User
330 |
--------------------------------------------------------------------------------
/webApp/server/public-api.js:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 |
3 | import Payment from './models/Payment'
4 | import User from './models/User'
5 | import logger from './log'
6 |
7 | const router = express.Router()
8 |
9 | router.get('/get-mentor-detail/:slug', (req, res) => {
10 | User.findOne({ slug: req.params.slug }, User.publicFields())
11 | .then(user => {
12 | if (!user) {
13 | return Promise.reject(new Error('Mentor not found'))
14 | }
15 |
16 | return res.json(user.toObject())
17 | })
18 | .catch(err => res.json({ error: err.message || err.toString() }))
19 | })
20 |
21 | router.post('/create-payment', (req, res) => {
22 | const { stripeToken, mentorId, email } = req.body
23 |
24 | if (!stripeToken || !mentorId || !email) {
25 | res.json({ error: 'Invalid data' })
26 | return
27 | }
28 |
29 | Payment.createNewPayment({ userId: mentorId, stripeToken, email })
30 | .then(() => {
31 | res.json({ saved: true })
32 | })
33 | .catch(err => {
34 | logger.error(err)
35 | res.json({ error: err.message || err.toString() })
36 | })
37 | })
38 |
39 | router.post('/pay-payment/:paymentId', (req, res) => {
40 | const { stripeToken } = req.body
41 |
42 | if (!stripeToken) {
43 | res.json({ error: 'Invalid data' })
44 | return
45 | }
46 |
47 | Payment.findById(req.params.paymentId, 'amount userId email')
48 | .then(payment => {
49 | if (!payment) {
50 | throw new Error('Not found')
51 | }
52 |
53 | return payment.chargeDebt({ stripeToken })
54 | })
55 | .then(() => {
56 | res.json({ charged: true })
57 | })
58 | .catch(err => {
59 | logger.error(err)
60 | res.json({ error: err.message || err.toString() })
61 | })
62 | })
63 |
64 | export default router
65 |
--------------------------------------------------------------------------------
/webApp/server/sitemap.js:
--------------------------------------------------------------------------------
1 | import sm from 'sitemap'
2 | import User from './models/User'
3 |
4 | const sitemap = sm.createSitemap({
5 | hostname: 'https://app.findharbor.com',
6 | cacheTime: 600000 // 600 sec - cache purge period
7 | })
8 |
9 | export default function setup({ server }) {
10 | User.find({ isStripeConnected: true, isMentorPagePublic: true }, 'slug').then(users => {
11 | users.forEach(user => {
12 | sitemap.add({ url: `/contact/${user.slug}`, changefreq: 'weekly', priority: 0.9 })
13 | })
14 | })
15 |
16 | server.get('/sitemap.xml', (req, res) => {
17 | sitemap.toXML((err, xml) => {
18 | if (err) {
19 | res.status(500).end()
20 | return
21 | }
22 |
23 | res.header('Content-Type', 'application/xml')
24 | res.send(xml)
25 | })
26 | })
27 |
28 | server.get('/robots.txt', (req, res) => {
29 | res.header('Content-Type', 'text/plain;charset=UTF-8')
30 | res.end(
31 | 'User-agent: *\nDisallow: /auth/google\nDisallow: /contact$\nSitemap: https://app.findharbor.com/sitemap.xml'
32 | )
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/webApp/server/stripe.js:
--------------------------------------------------------------------------------
1 | import qs from 'qs'
2 | import request from 'request'
3 | import stripe from 'stripe'
4 |
5 | import User from './models/User'
6 |
7 | const TOKEN_URI = 'https://connect.stripe.com/oauth/token'
8 | const AUTHORIZE_URI = 'https://connect.stripe.com/oauth/authorize'
9 |
10 | const SIMULATE_CHARGE_FAILED = !!process.env.SIMULATE_CHARGE_FAILED
11 |
12 | export function setup({ server }) {
13 | const dev = process.env.NODE_ENV !== 'production'
14 |
15 | const CLIENT_ID = dev ? process.env.Stripe_Test_ClientID : process.env.Stripe_Live_ClientID
16 | const API_KEY = dev ? process.env.Stripe_Test_SecretKey : process.env.Stripe_Live_SecretKey
17 |
18 | server.get('/auth/stripe', (req, res) => {
19 | if (!req.user) {
20 | res.redirect('/login')
21 | return
22 | }
23 |
24 | // Generate a random string as state to protect from CSRF and place it in the session.
25 | req.session.state = Math.random().toString(36).slice(2)
26 |
27 | // Redirect to Stripe /oauth/authorize endpoint
28 | res.redirect(
29 | `${AUTHORIZE_URI}?${qs.stringify({
30 | response_type: 'code',
31 | scope: 'read_write',
32 | state: req.session.state,
33 | client_id: CLIENT_ID
34 | })}`
35 | )
36 | })
37 |
38 | server.get('/auth/stripe/callback', (req, res) => {
39 | if (!req.user) {
40 | res.redirect('/login')
41 | return
42 | }
43 |
44 | // Check the state we got back equals the one we generated before proceeding.
45 | if (req.session.state !== req.query.state) {
46 | res.redirect('/?error=Invalid request')
47 | return
48 | }
49 |
50 | if (req.query.error) {
51 | res.redirect(`/?error=${req.query.error_description}`)
52 | return
53 | }
54 |
55 | const code = req.query.code
56 |
57 | request.post(
58 | {
59 | url: TOKEN_URI,
60 | form: {
61 | grant_type: 'authorization_code',
62 | client_id: CLIENT_ID,
63 | code,
64 | client_secret: API_KEY
65 | }
66 | },
67 | (err, r, body) => {
68 | if (err) {
69 | res.redirect(`/?error=${err.message || err.toString()}`)
70 | return
71 | }
72 |
73 | const result = JSON.parse(body)
74 |
75 | if (result.error) {
76 | res.redirect(`/?error=${result.error_description}`)
77 | return
78 | }
79 |
80 | User.updateOne(
81 | { _id: req.user.id },
82 | { $set: { isStripeConnected: true, stripeCustomer: result } }
83 | )
84 | .then(() => res.redirect('/'))
85 | .catch(err2 => res.redirect(`/?error=${err2.message || err2.toString()}`))
86 | }
87 | )
88 | })
89 | }
90 |
91 | export function createCustomer({ token, email }) {
92 | const dev = process.env.NODE_ENV !== 'production'
93 |
94 | const API_KEY = dev ? process.env.Stripe_Test_SecretKey : process.env.Stripe_Live_SecretKey
95 | const client = stripe(API_KEY)
96 |
97 | return client.customers.create({
98 | email,
99 | source: token
100 | })
101 | }
102 |
103 | export function charge({ customerId, amount, account, mentorName, mentorEmail, customerEmail }) {
104 | const dev = process.env.NODE_ENV !== 'production'
105 |
106 | const API_KEY = dev ? process.env.Stripe_Test_SecretKey : process.env.Stripe_Live_SecretKey
107 | const client = stripe(API_KEY)
108 |
109 | // charge exactly 10% including Stripe fee
110 | const fee = amount * 0.1 - Math.round(amount * 0.029) - 30
111 |
112 | return client.tokens
113 | .create(
114 | {
115 | customer: customerId
116 | },
117 | {
118 | stripe_account: account
119 | }
120 | )
121 | .then(token =>
122 | client.charges.create(
123 | {
124 | amount,
125 | application_fee: fee,
126 | currency: 'usd',
127 | source: SIMULATE_CHARGE_FAILED ? 'tok_chargeDeclined' : token.id,
128 | receipt_email: customerEmail,
129 | description: `Payment from ${customerEmail} to ${mentorEmail} (${mentorName}) for advice via email`
130 | },
131 | {
132 | stripe_account: account
133 | }
134 | )
135 | )
136 | }
137 |
--------------------------------------------------------------------------------
/webApp/server/utils/slugify.js:
--------------------------------------------------------------------------------
1 | const slugify = text =>
2 | text
3 | .toString()
4 | .toLowerCase()
5 | .trim()
6 | // Replace spaces with -
7 | .replace(/\s+/g, '-')
8 | // Replace & with 'and'
9 | .replace(/&/g, '-and-')
10 | // Remove all non-word chars
11 | .replace(/(?!\w)[\x00-\xC0]/g, '-') // eslint-disable-line
12 | // Replace multiple - with single -
13 | .replace(/\-\-+/g, '-') // eslint-disable-line
14 |
15 | function createUniqueSlug(Model, slug, count) {
16 | return Model.findOne({ slug: `${slug}-${count}` }, 'id').then(user => {
17 | if (!user) {
18 | return Promise.resolve(`${slug}-${count}`)
19 | }
20 |
21 | return createUniqueSlug(Model, slug, count + 1)
22 | })
23 | }
24 |
25 | export default function generateSlug(Model, name) {
26 | const origSlug = slugify(name)
27 |
28 | return Model.findOne({ slug: origSlug }, 'id').then(user => {
29 | if (!user) {
30 | return Promise.resolve(origSlug)
31 | }
32 |
33 | return createUniqueSlug(Model, origSlug, 2)
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/webApp/static/nprogress.css:
--------------------------------------------------------------------------------
1 | /* Make clicks pass-through */
2 | #nprogress {
3 | pointer-events: none;
4 | }
5 |
6 | #nprogress .bar {
7 | background: #1f4167;
8 |
9 | position: fixed;
10 | z-index: 1031;
11 | top: 0;
12 | left: 0;
13 |
14 | width: 100%;
15 | height: 2px;
16 | }
17 |
18 | /* Fancy blur effect */
19 | #nprogress .peg {
20 | display: block;
21 | position: absolute;
22 | right: 0px;
23 | width: 100px;
24 | height: 100%;
25 | box-shadow: 0 0 10px #1f4167, 0 0 5px #1f4167;
26 | opacity: 1.0;
27 |
28 | -webkit-transform: rotate(3deg) translate(0px, -4px);
29 | -ms-transform: rotate(3deg) translate(0px, -4px);
30 | transform: rotate(3deg) translate(0px, -4px);
31 | }
32 |
--------------------------------------------------------------------------------
/webApp/test/unit/utils/slugify.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 |
3 | import generateSlug from '../../../server/utils/slugify'
4 |
5 | describe('slugify', () => {
6 | it('not duplicated', () => {
7 | expect.assertions(1)
8 |
9 | const User = {
10 | findOne() {
11 | return Promise.resolve(null)
12 | }
13 | }
14 |
15 | return generateSlug(User, 'John & Jonhson@#$').then(slug => {
16 | expect(slug).toBe('john-and-jonhson-')
17 | })
18 | })
19 |
20 | it('one time duplicated', () => {
21 | expect.assertions(1)
22 |
23 | const User = {
24 | slug: 'john-and-jonhson-',
25 | findOne({ slug }) {
26 | if (this.slug === slug) {
27 | return Promise.resolve({ id: 'id' })
28 | }
29 |
30 | return Promise.resolve(null)
31 | }
32 | }
33 |
34 | return generateSlug(User, 'John & Jonhson@#$').then(slug => {
35 | expect(slug).toBe('john-and-jonhson--2')
36 | })
37 | })
38 |
39 | it('multiple duplicated', () => {
40 | expect.assertions(1)
41 |
42 | const User = {
43 | slugs: ['john-and-jonhson-', 'john-and-jonhson--2'],
44 | findOne({ slug }) {
45 | if (this.slugs.includes(slug)) {
46 | return Promise.resolve({ id: 'id' })
47 | }
48 |
49 | return Promise.resolve(null)
50 | }
51 | }
52 |
53 | return generateSlug(User, 'John & Jonhson@#$').then(slug => {
54 | expect(slug).toBe('john-and-jonhson--3')
55 | })
56 | })
57 | })
58 |
--------------------------------------------------------------------------------