├── .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 |
{error.message}
64 | > 65 | ); 66 | } 67 | 68 | const handleFilterChange = (event) => { 69 | setFilter(event.target.value); 70 | }; 71 | 72 | return ( 73 | <> 74 |