├── .gitignore ├── db.js ├── functions ├── auth.js └── graphql.js ├── gatsby-config.js ├── netlify.toml ├── package.json ├── src ├── components │ ├── company-select.js │ ├── contact-form.js │ ├── contact-select.js │ ├── create-listing.js │ ├── listing-form.js │ ├── listing-menu.js │ ├── listings.js │ ├── login-form.js │ └── remove-contact-button.js ├── gatsby-theme-apollo │ └── client.js ├── pages │ └── index.js └── utils.js ├── utils.js ├── yarn-error.log └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | public 4 | .env* 5 | 6 | # Local Netlify folder 7 | .netlify -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | 3 | const sequelize = new Sequelize(process.env.DB_CONNECTION_STRING, { 4 | dialectOptions: { 5 | ssl: true, 6 | }, 7 | }); 8 | 9 | class User extends Sequelize.Model {} 10 | User.init( 11 | { 12 | email: Sequelize.STRING, 13 | password: Sequelize.STRING, 14 | }, 15 | { 16 | sequelize, 17 | modelName: "user", 18 | } 19 | ); 20 | 21 | class Contact extends Sequelize.Model {} 22 | Contact.init( 23 | { 24 | name: Sequelize.STRING, 25 | notes: Sequelize.TEXT, 26 | }, 27 | { 28 | sequelize, 29 | modelName: "contact", 30 | } 31 | ); 32 | 33 | Contact.belongsTo(User); 34 | User.hasMany(Contact); 35 | 36 | class Listing extends Sequelize.Model {} 37 | Listing.init( 38 | { 39 | title: Sequelize.STRING, 40 | description: Sequelize.TEXT, 41 | url: Sequelize.STRING, 42 | notes: Sequelize.TEXT, 43 | }, 44 | { 45 | sequelize, 46 | modelName: "listing", 47 | } 48 | ); 49 | 50 | Listing.belongsTo(User); 51 | User.hasMany(Listing); 52 | 53 | Listing.belongsToMany(Contact, { through: "listing_contacts" }); 54 | Contact.belongsToMany(Listing, { through: "listing_contacts" }); 55 | 56 | class Company extends Sequelize.Model {} 57 | Company.init( 58 | { 59 | name: Sequelize.STRING, 60 | }, 61 | { 62 | sequelize, 63 | modelName: "company", 64 | } 65 | ); 66 | 67 | Listing.belongsTo(Company); 68 | Company.hasMany(Listing); 69 | 70 | sequelize.sync(); 71 | 72 | exports.sequelize = sequelize; 73 | exports.User = User; 74 | exports.Listing = Listing; 75 | exports.Company = Company; 76 | exports.Contact = Contact; 77 | -------------------------------------------------------------------------------- /functions/auth.js: -------------------------------------------------------------------------------- 1 | const Sequelize = require("sequelize"); 2 | const bcrypt = require("bcryptjs"); 3 | const basicAuth = require("basic-auth"); 4 | const jwt = require("jsonwebtoken"); 5 | const { sequelize, User } = require("../db"); 6 | 7 | exports.handler = async (event) => { 8 | // this is a hack to reset DB and create a test user 9 | // await sequelize.sync({ force: true }); 10 | 11 | // const salt = bcrypt.genSaltSync(10); 12 | // const hash = bcrypt.hashSync("password", salt); 13 | // const user = await User.create({ 14 | // email: "test@theworst.dev", 15 | // password: hash, 16 | // }); 17 | 18 | try { 19 | const { name, pass } = basicAuth(event); 20 | const user = await User.findOne({ 21 | where: { 22 | email: { 23 | [Sequelize.Op.iLike]: name, 24 | }, 25 | }, 26 | }); 27 | 28 | if (user) { 29 | const passwordsMatch = await bcrypt.compare(pass, user.password); 30 | if (passwordsMatch) { 31 | const token = jwt.sign( 32 | { 33 | id: user.id, 34 | email: user.email, 35 | }, 36 | process.env.JWT_SECRET 37 | ); 38 | return { 39 | statusCode: 200, 40 | body: token, 41 | }; 42 | } 43 | } 44 | 45 | return { 46 | statusCode: 401, 47 | body: "Incorrect email/password combination", 48 | }; 49 | } catch (err) { 50 | return { 51 | statusCode: 400, 52 | body: `We encountered an error: ${err.message}`, 53 | }; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /functions/graphql.js: -------------------------------------------------------------------------------- 1 | const { 2 | ApolloServer, 3 | AuthenticationError, 4 | ForbiddenError, 5 | gql, 6 | } = require("apollo-server-lambda"); 7 | const { Listing, User, Company, Contact } = require("../db"); 8 | const jwt = require("jsonwebtoken"); 9 | 10 | const typeDefs = gql` 11 | type Query { 12 | listings: [Listing!]! 13 | companies: [Company!]! 14 | contacts: [Contact!]! 15 | } 16 | 17 | type Mutation { 18 | createListing(input: CreateListingInput!): Listing! 19 | updateListing(input: UpdateListingInput!): Listing! 20 | deleteListing(id: ID!): Listing! 21 | createContact(input: CreateContactInput!): ListingContact! 22 | removeContact(input: RemoveContactInput!): ListingContact! 23 | addContactToListing(contactId: ID!, listingId: ID!): ListingContact! 24 | } 25 | 26 | input CreateListingInput { 27 | title: String! 28 | description: String 29 | url: String! 30 | notes: String 31 | newCompany: String 32 | companyId: ID 33 | } 34 | 35 | input UpdateListingInput { 36 | id: ID! 37 | title: String! 38 | description: String 39 | url: String! 40 | notes: String 41 | newCompany: String 42 | companyId: ID 43 | } 44 | 45 | input CreateContactInput { 46 | name: String! 47 | notes: String! 48 | listingId: ID! 49 | } 50 | 51 | input RemoveContactInput { 52 | id: ID! 53 | listingId: ID! 54 | } 55 | 56 | type Contact { 57 | id: ID! 58 | name: String! 59 | notes: String! 60 | } 61 | 62 | type ListingContact { 63 | contact: Contact! 64 | listingId: ID! 65 | } 66 | 67 | type Company { 68 | id: ID! 69 | name: String! 70 | listings: [Listing!]! 71 | } 72 | 73 | type Listing { 74 | id: ID! 75 | title: String! 76 | description: String 77 | url: String! 78 | notes: String 79 | company: Company 80 | contacts: [Contact!]! 81 | } 82 | `; 83 | 84 | const resolvers = { 85 | Listing: { 86 | company: (listing) => listing.getCompany(), 87 | contacts: (listing) => listing.getContacts(), 88 | }, 89 | Query: { 90 | listings(_, __, { user }) { 91 | return user.getListings({ 92 | order: [["id", "desc"]], 93 | }); 94 | }, 95 | companies: () => Company.findAll(), 96 | contacts: (_, __, { user }) => user.getContacts(), 97 | }, 98 | Mutation: { 99 | async createContact(_, { input }, { user }) { 100 | const { listingId, ...rest } = input; 101 | const contact = await Contact.create({ 102 | ...rest, 103 | userId: user.id, 104 | }); 105 | 106 | await contact.addListing(listingId); 107 | return { 108 | contact, 109 | listingId, 110 | }; 111 | }, 112 | async addContactToListing(_, { listingId, contactId }, { user }) { 113 | const listing = await Listing.findOne({ 114 | where: { 115 | id: listingId, 116 | userId: user.id, 117 | }, 118 | }); 119 | 120 | if (!listing) { 121 | throw new ForbiddenError("You do not have access to this listing"); 122 | } 123 | 124 | const contact = await Contact.findOne({ 125 | where: { 126 | id: contactId, 127 | userId: user.id, 128 | }, 129 | }); 130 | 131 | if (!contact) { 132 | throw new ForbiddenError("You do not have access to this contact"); 133 | } 134 | 135 | await listing.addContact(contact); 136 | 137 | return { 138 | listingId: listing.id, 139 | contact, 140 | }; 141 | }, 142 | async removeContact(_, { input }, { user }) { 143 | const { id, listingId } = input; 144 | 145 | const listing = await Listing.findOne({ 146 | where: { 147 | id: listingId, 148 | userId: user.id, 149 | }, 150 | }); 151 | 152 | if (!listing) { 153 | throw new ForbiddenError("You do not have access to this listing"); 154 | } 155 | 156 | const contact = await Contact.findByPk(id); 157 | 158 | await listing.removeContact(contact); 159 | 160 | return { contact, listingId }; 161 | }, 162 | async createListing(_, args, { user }) { 163 | const { newCompany, ...input } = args.input; 164 | 165 | if (newCompany) { 166 | const company = await Company.create({ name: newCompany }); 167 | input.companyId = company.id; 168 | } 169 | 170 | const listing = await Listing.create({ 171 | ...input, 172 | userId: user.id, 173 | }); 174 | 175 | return listing; 176 | }, 177 | async updateListing(_, args, { user }) { 178 | const { id, ...input } = args.input; 179 | const listing = await Listing.findOne({ 180 | where: { 181 | id, 182 | userId: user.id, 183 | }, 184 | }); 185 | 186 | if (!listing) { 187 | throw new ForbiddenError("You do not have access to this listing"); 188 | } 189 | 190 | return listing.update(input); 191 | }, 192 | async deleteListing(_, { id }, { user }) { 193 | const listing = await Listing.findOne({ 194 | where: { 195 | id, 196 | userId: user.id, 197 | }, 198 | }); 199 | 200 | if (!listing) { 201 | throw new ForbiddenError("You do not have access to this listing"); 202 | } 203 | 204 | await listing.destroy(); 205 | return listing; 206 | }, 207 | }, 208 | }; 209 | 210 | const server = new ApolloServer({ 211 | typeDefs, 212 | resolvers, 213 | async context({ event }) { 214 | try { 215 | const token = event.headers.authorization.replace(/bearer\s+/i, ""); 216 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 217 | const user = await User.findByPk(decoded.id); 218 | 219 | if (!user) { 220 | throw new Error("User not found"); 221 | } 222 | 223 | return { user }; 224 | } catch (error) { 225 | throw new AuthenticationError("Unauthorized"); 226 | } 227 | }, 228 | }); 229 | 230 | exports.handler = server.createHandler(); 231 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | siteMetadata: { 3 | title: "Journey", 4 | }, 5 | plugins: [ 6 | "gatsby-plugin-react-helmet", 7 | "gatsby-theme-apollo", 8 | { 9 | resolve: "gatsby-plugin-chakra-ui", 10 | options: { 11 | isUsingColorMode: false, 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = 'gatsby build' 3 | publish = 'public' 4 | functions = 'functions' 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "start": "gatsby develop" 4 | }, 5 | "dependencies": { 6 | "@apollo/client": "^3.0.0-beta.41", 7 | "@chakra-ui/core": "^0.7.0", 8 | "@emotion/core": "^10.0.28", 9 | "@emotion/styled": "^10.0.27", 10 | "apollo-server-lambda": "^2.11.0", 11 | "basic-auth": "^2.0.1", 12 | "bcryptjs": "^2.4.3", 13 | "emotion-theming": "^10.0.27", 14 | "framer-motion": "^1.11.0", 15 | "gatsby": "^2.20.7", 16 | "gatsby-plugin-chakra-ui": "^0.1.4", 17 | "gatsby-plugin-react-helmet": "^3.2.1", 18 | "gatsby-theme-apollo": "^3.0.2", 19 | "graphql": "^15.0.0", 20 | "isomorphic-fetch": "^2.2.1", 21 | "jsonwebtoken": "^8.5.1", 22 | "pg": "^8.0.1", 23 | "pg-hstore": "^2.3.3", 24 | "react": "^16.13.1", 25 | "react-dom": "^16.13.1", 26 | "react-helmet": "^5.2.1", 27 | "sequelize": "^5.21.6" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/company-select.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { gql, useQuery } from "@apollo/client"; 3 | import { Select } from "@chakra-ui/core"; 4 | 5 | const GET_COMPANIES = gql` 6 | query GetCompanies { 7 | companies { 8 | id 9 | name 10 | } 11 | } 12 | `; 13 | 14 | export default function CompanySelect({ children, ...props }) { 15 | const { data, loading, error } = useQuery(GET_COMPANIES); 16 | return ( 17 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/contact-form.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Button, 4 | Stack, 5 | ModalBody, 6 | ModalFooter, 7 | Textarea, 8 | Input, 9 | Text, 10 | } from "@chakra-ui/core"; 11 | import { AnimatePresence, motion } from "framer-motion"; 12 | import ContactSelect from "./contact-select"; 13 | import { gql, useMutation } from "@apollo/client"; 14 | import { CONTACT_FRAGMENT, GET_CONTACTS, GET_LISTINGS } from "../utils"; 15 | 16 | function CreateOrSelectContact(props) { 17 | const [contactId, setContactId] = useState(""); 18 | 19 | function handleContactChange(event) { 20 | setContactId(event.target.value); 21 | } 22 | 23 | return ( 24 | 25 | 26 | 31 | 32 | 33 | 34 | {contactId === "new" && ( 35 | 49 | 50 | 51 |