├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .postgraphilerc.js ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── client-react ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── App.test.js │ ├── apolloClient.js │ ├── components │ ├── CreateNewForumForm.js │ ├── CreateNewReplyForm.js │ ├── CreateNewTopicForm.js │ ├── ForumItem.js │ ├── ForumPage.js │ ├── Header.js │ ├── HomePage.js │ ├── LoginPage.js │ ├── Main.js │ ├── NotFound.js │ ├── PostItem.js │ ├── TopicItem.js │ └── TopicPage.js │ ├── index.js │ ├── layouts │ └── StandardLayout.js │ ├── logo.svg │ ├── registerServiceWorker.js │ └── routes │ ├── ForumRoute.js │ ├── HomeRoute.js │ ├── LoginRoute.js │ ├── NotFoundRoute.js │ └── TopicRoute.js ├── data ├── README.md ├── schema.graphql ├── schema.json └── schema.sql ├── db ├── 100_jobs.sql ├── 200_schemas.sql ├── 300_utils.sql ├── 400_users.sql ├── 700_forum.sql ├── 999_data.sql ├── CONVENTIONS.md ├── README.md └── reset.sql ├── package.json ├── public └── css │ └── index.css ├── scripts └── schema_dump ├── server-koa2 ├── middleware │ ├── index.js │ ├── installFrontendServer.js │ ├── installPassport.js │ ├── installPostGraphile.js │ ├── installSession.js │ ├── installSharedStatic.js │ └── installStandardKoaMiddlewares.js ├── package.json └── server.js ├── setup.sh ├── shared ├── plugins │ └── PassportLoginPlugin.js └── utils.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _LOCAL 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "babel-eslint", 3 | parserOptions: { 4 | sourceType: "module", 5 | }, 6 | extends: ["eslint:recommended", "plugin:react/recommended", "prettier"], 7 | plugins: ["prettier", "graphql", "react"], 8 | env: { 9 | browser: true, 10 | es6: true, 11 | jest: true, 12 | node: true, 13 | }, 14 | rules: { 15 | "prettier/prettier": [ 16 | "error", 17 | { 18 | trailingComma: "es5", 19 | }, 20 | ], 21 | "no-unused-vars": [ 22 | 2, 23 | { 24 | argsIgnorePattern: "^_", 25 | }, 26 | ], 27 | "no-console": 0, // This is a demo, so logging console messages can be helpful. Remove this in production! 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | data/graphql.json linguist-generated 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.11.3 2 | -------------------------------------------------------------------------------- /.postgraphilerc.js: -------------------------------------------------------------------------------- 1 | const PgSimplifyInflectorPlugin = require("@graphile-contrib/pg-simplify-inflector"); 2 | 3 | ["AUTH_DATABASE_URL", "NODE_ENV"].forEach(envvar => { 4 | if (!process.env[envvar]) { 5 | // We automatically source `.env` in the various scripts; but in case that 6 | // hasn't been done lets raise an error and stop. 7 | console.error(""); 8 | console.error(""); 9 | console.error("⚠️⚠️⚠️⚠️"); 10 | console.error( 11 | `No ${envvar} found in your environment; perhaps you need to run 'source ./.env'?` 12 | ); 13 | console.error("⚠️⚠️⚠️⚠️"); 14 | console.error(""); 15 | process.exit(1); 16 | } 17 | }); 18 | 19 | const isDev = process.env.NODE_ENV === "development"; 20 | 21 | // Our database URL - privileged 22 | const ownerConnection = process.env.ROOT_DATABASE_URL; 23 | // Our database URL - unprivileged 24 | const connection = process.env.AUTH_DATABASE_URL; 25 | // The PostgreSQL schema within our postgres DB to expose 26 | const schema = ["app_public"]; 27 | // Enable GraphiQL interface 28 | const graphiql = true; 29 | // Send back JSON objects rather than JSON strings 30 | const dynamicJson = true; 31 | // Watch the database for changes 32 | const watch = true; 33 | // Add some Graphile-Build plugins to enhance our GraphQL schema 34 | const appendPlugins = [ 35 | // Removes the 'ByFooIdAndBarId' from the end of relations 36 | PgSimplifyInflectorPlugin, 37 | ]; 38 | 39 | module.exports = { 40 | // Config for the library (middleware): 41 | library: { 42 | connection, 43 | schema, 44 | options: { 45 | ownerConnectionString: ownerConnection, 46 | dynamicJson, 47 | graphiql, 48 | watchPg: watch, 49 | appendPlugins, 50 | }, 51 | }, 52 | // Options for the CLI: 53 | options: { 54 | ownerConnection, 55 | defaultRole: "graphiledemo_visitor", 56 | connection, 57 | schema, 58 | dynamicJson, 59 | disableGraphiql: !graphiql, 60 | enhanceGraphiql: true, 61 | ignoreRbac: false, 62 | // We don't set a watch mode here, because there's no way to turn it off (e.g. when using -X) currently. 63 | appendPlugins, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © `2018` Benjie Gillam 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PostGraphile Examples 2 | ===================== 3 | 4 | 🚨**Temporarily unmaintained**🚨 This repo is currently not maintained and is out of date - Please make sure that you update the dependancies in your copy of these examples. For an up to date example, see the [Graphile Starter](https://github.com/graphile/starter). *We rely on sponsorship from the Graphile community to continue our work in Open Source. By donating to our [GitHub Sponsors or Patreon fund](https://graphile.org/sponsor), you'll help us spend more time on Open Source, and this repo will be updated quicker. Thank you to all our sponsors 🙌* 5 | 6 | This repository will contain examples of using PostGraphile with different servers and clients. 7 | 8 | To get started: 9 | 10 | ``` 11 | npm install -g yarn 12 | yarn 13 | ./setup.sh 14 | # Now add GITHUB_KEY and GITHUB_SECRET to .env (see "Login via GitHub" below) 15 | yarn start 16 | ``` 17 | 18 | This will run the koa2 server and react client. You can access it at http://localhost:8349/ 19 | 20 | It's recommended that you review the setup.sh script before executing it. 21 | 22 | The first user account to log in will automatically be made an administrator. 23 | 24 | Login via GitHub 25 | ---------------- 26 | 27 | To use social login you will need to create a GitHub application. This takes just a few seconds: 28 | 29 | 1. Visit https://github.com/settings/applications/new 30 | 2. Enter name: GraphileDemo 31 | 3. Enter homepage URL: http://localhost:8349 32 | 4. Enter authorization callback URL: http://localhost:8349/auth/github/callback 33 | 5. Press "Register Application" 34 | 6. Copy the 'Client ID' and 'Client Secret' into `GITHUB_KEY` and `GITHUB_SECRET` respectively in the `.env` file that was created by `setup.sh` 35 | 36 | Koa2 37 | ---- 38 | 39 | Koa 2 only has "experimental" support in PostGraphile officially, but if you 40 | face any issues please file them against PostGraphile with full reproduction 41 | instructions - we're trying to elevate Koa to full support status. 42 | -------------------------------------------------------------------------------- /client-react/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "apollo-cache-inmemory": "1.3.0", 7 | "apollo-client": "2.3.5", 8 | "apollo-link-http": "1.5.4", 9 | "graphql": "0.13.2", 10 | "graphql-anywhere": "4.1.14", 11 | "graphql-tag": "2.9.2", 12 | "moment": "2.29.4", 13 | "prop-types": "15.6.2", 14 | "react": "^16.4.1", 15 | "react-apollo": "2.5.0", 16 | "react-dom": "^16.4.1", 17 | "react-router-dom": "4.3.1", 18 | "react-scripts": "5.0.0", 19 | "slug": "0.9.2" 20 | }, 21 | "scripts": { 22 | "start": "if [ -x ../.env ]; then . ../.env; fi; BROWSER=none PORT=$CLIENT_PORT react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client-react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphile/examples/67d34c4d22b72544fa134eb714610adb0ed73d3d/client-react/public/favicon.ico -------------------------------------------------------------------------------- /client-react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 | React App 24 | 25 | 26 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /client-react/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client-react/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Switch, Route } from "react-router-dom"; 3 | import HomeRoute from "./routes/HomeRoute"; 4 | import NotFoundRoute from "./routes/NotFoundRoute"; 5 | import LoginRoute from "./routes/LoginRoute"; 6 | import ForumRoute from "./routes/ForumRoute"; 7 | import TopicRoute from "./routes/TopicRoute"; 8 | 9 | class App extends Component { 10 | render() { 11 | if (typeof window !== "undefined" && window.location.port === "8350") { 12 | return ( 13 |
14 |

15 | Greetings and saluations!{" "} 16 | 17 | 🧐 18 | 19 |

20 |

21 | Terribly sorry about this old bean, but you appear to have visited 22 | the create-react-app app directly. 23 |

24 |

25 | Instead, you should visit the server app, which proxies through to 26 | create-react-app but adds all the GraphQL and OAuth goodness. 27 |

28 |

29 | Click here to visit the server, 30 | assuming you stuck with the default PORT=8349 31 |

32 |
33 | ); 34 | } 35 | return ( 36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | ); 46 | } 47 | } 48 | 49 | export default App; 50 | -------------------------------------------------------------------------------- /client-react/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /client-react/src/apolloClient.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient } from "apollo-client"; 2 | import { HttpLink } from "apollo-link-http"; 3 | import { InMemoryCache } from "apollo-cache-inmemory"; 4 | 5 | export default function makeClient() { 6 | return new ApolloClient({ 7 | link: new HttpLink({ 8 | uri: "/graphql", 9 | credentials: "same-origin", 10 | }), 11 | cache: new InMemoryCache({ 12 | dataIdFromObject: object => object.nodeId, 13 | }), 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /client-react/src/components/CreateNewForumForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Mutation } from "react-apollo"; 5 | import _slug from "slug"; 6 | import PropTypes from "prop-types"; 7 | 8 | const autoSlug = str => _slug(str, _slug.defaults.modes["rfc3986"]); 9 | 10 | const CreateForumMutation = gql` 11 | mutation CreateForumMutation( 12 | $slug: String! 13 | $name: String! 14 | $description: String! 15 | ) { 16 | createForum( 17 | input: { forum: { slug: $slug, name: $name, description: $description } } 18 | ) { 19 | forum { 20 | nodeId 21 | id 22 | slug 23 | name 24 | description 25 | } 26 | } 27 | } 28 | `; 29 | 30 | export default class CreateNewForumForm extends React.Component { 31 | static QueryFragment = gql` 32 | fragment CreateNewForumForm_QueryFragment on Query { 33 | currentUser { 34 | nodeId 35 | isAdmin 36 | } 37 | } 38 | `; 39 | 40 | static propTypes = { 41 | data: propType(CreateNewForumForm.QueryFragment), 42 | onCreateForum: PropTypes.func, 43 | }; 44 | 45 | state = { 46 | slug: null, 47 | name: "", 48 | description: "", 49 | }; 50 | 51 | handleChange = key => e => { 52 | this.setState({ [key]: e.target.value, error: null }); 53 | }; 54 | 55 | handleSuccess = ({ 56 | data: { 57 | createForum: { forum }, 58 | }, 59 | }) => { 60 | console.dir(forum); 61 | this.setState({ 62 | sending: false, 63 | slug: null, 64 | name: "", 65 | description: "", 66 | }); 67 | if (typeof this.props.onCreateForum === "function") { 68 | this.props.onCreateForum(forum); 69 | } 70 | }; 71 | 72 | handleError = e => { 73 | this.setState({ sending: false, error: e }); 74 | }; 75 | 76 | slug = () => 77 | this.state.slug != null ? this.state.slug : autoSlug(this.state.name); 78 | 79 | render() { 80 | return ( 81 | 82 | {createNewMutation => ( 83 |
{ 85 | e.preventDefault(); 86 | if (this.state.sending) return; 87 | this.setState({ sending: true }); 88 | createNewMutation({ 89 | variables: { 90 | slug: this.slug(), 91 | name: this.state.name, 92 | description: this.state.description, 93 | }, 94 | }).then(this.handleSuccess, this.handleError); 95 | }} 96 | > 97 | 98 | 99 | 100 | 101 | 108 | 109 | 110 | 111 | 118 | 119 | 120 | 121 | 128 | 129 | 130 |
Name 102 | 107 |
Url 112 | 117 |
Description 122 | 127 |
131 | {this.state.error ? ( 132 |

133 | An error occurred! {this.state.error.message} 134 |

135 | ) : null} 136 | 139 |
140 | )} 141 |
142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /client-react/src/components/CreateNewReplyForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Mutation } from "react-apollo"; 5 | import PropTypes from "prop-types"; 6 | 7 | const CreatePostMutation = gql` 8 | mutation CreatePostMutation($body: String!, $topicId: Int!) { 9 | createPost(input: { post: { body: $body, topicId: $topicId } }) { 10 | post { 11 | nodeId 12 | id 13 | body 14 | topicId 15 | } 16 | } 17 | } 18 | `; 19 | 20 | export default class CreateNewReplyForm extends React.Component { 21 | static QueryFragment = gql` 22 | fragment CreateNewReplyForm_QueryFragment on Query { 23 | currentUser { 24 | nodeId 25 | } 26 | } 27 | `; 28 | 29 | static propTypes = { 30 | data: propType(CreateNewReplyForm.QueryFragment), 31 | onCreatePost: PropTypes.func, 32 | }; 33 | 34 | state = { 35 | body: "", 36 | }; 37 | 38 | handleChange = key => e => { 39 | this.setState({ [key]: e.target.value, error: null }); 40 | }; 41 | 42 | handleSuccess = ({ 43 | data: { 44 | createPost: { forum }, 45 | }, 46 | }) => { 47 | this.setState({ 48 | sending: false, 49 | body: "", 50 | }); 51 | if (typeof this.props.onCreatePost === "function") { 52 | this.props.onCreatePost(forum); 53 | } 54 | }; 55 | 56 | handleError = e => { 57 | this.setState({ sending: false, error: e }); 58 | }; 59 | 60 | render() { 61 | const { data } = this.props; 62 | const { topic } = data; 63 | return ( 64 | 65 | {createNewMutation => ( 66 |
{ 68 | e.preventDefault(); 69 | if (this.state.sending) return; 70 | this.setState({ sending: true }); 71 | createNewMutation({ 72 | variables: { 73 | body: this.state.body, 74 | topicId: topic.id, 75 | }, 76 | }).then(this.handleSuccess, this.handleError); 77 | }} 78 | > 79 | 80 | 81 | 82 | 83 | 90 | 91 | 92 |
Reply 84 | 89 |
93 | {this.state.error ? ( 94 |

95 | An error occurred! {this.state.error.message} 96 |

97 | ) : null} 98 | 101 |
102 | )} 103 |
104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /client-react/src/components/CreateNewTopicForm.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Mutation } from "react-apollo"; 5 | import _slug from "slug"; 6 | import PropTypes from "prop-types"; 7 | 8 | const autoSlug = str => _slug(str, _slug.defaults.modes["rfc3986"]); 9 | 10 | const CreateTopicMutation = gql` 11 | mutation CreateTopicMutation( 12 | $name: String! 13 | $description: String 14 | $forumId: Int! 15 | ) { 16 | createTopic( 17 | input: { topic: { title: $name, body: $description, forumId: $forumId } } 18 | ) { 19 | topic { 20 | nodeId 21 | id 22 | title 23 | body 24 | forumId 25 | } 26 | } 27 | } 28 | `; 29 | 30 | export default class CreateNewTopicForm extends React.Component { 31 | static QueryFragment = gql` 32 | fragment CreateNewTopicForm_QueryFragment on Query { 33 | currentUser { 34 | nodeId 35 | } 36 | } 37 | `; 38 | 39 | static propTypes = { 40 | data: propType(CreateNewTopicForm.QueryFragment), 41 | onCreateTopic: PropTypes.func, 42 | }; 43 | 44 | state = { 45 | slug: null, 46 | name: "", 47 | description: "", 48 | }; 49 | 50 | handleChange = key => e => { 51 | this.setState({ [key]: e.target.value, error: null }); 52 | }; 53 | 54 | handleSuccess = ({ 55 | data: { 56 | createTopic: { forum }, 57 | }, 58 | }) => { 59 | console.dir(forum); 60 | this.setState({ 61 | sending: false, 62 | slug: null, 63 | name: "", 64 | description: "", 65 | }); 66 | if (typeof this.props.onCreateTopic === "function") { 67 | this.props.onCreateTopic(forum); 68 | } 69 | }; 70 | 71 | handleError = e => { 72 | this.setState({ sending: false, error: e }); 73 | }; 74 | 75 | slug = () => 76 | this.state.slug != null ? this.state.slug : autoSlug(this.state.name); 77 | 78 | render() { 79 | const { data } = this.props; 80 | const { forum } = data; 81 | return ( 82 | 83 | {createNewMutation => ( 84 |
{ 86 | e.preventDefault(); 87 | if (this.state.sending) return; 88 | this.setState({ sending: true }); 89 | createNewMutation({ 90 | variables: { 91 | slug: this.slug(), 92 | name: this.state.name, 93 | description: this.state.description, 94 | forumId: forum.id, 95 | }, 96 | }).then(this.handleSuccess, this.handleError); 97 | }} 98 | > 99 | 100 | 101 | 102 | 103 | 110 | 111 | 112 | 113 | 120 | 121 | 122 | 123 | 130 | 131 | 132 |
Topic Name 104 | 109 |
Url 114 | 119 |
Description 124 | 129 |
133 | {this.state.error ? ( 134 |

135 | An error occurred! {this.state.error.message} 136 |

137 | ) : null} 138 | 141 |
142 | )} 143 |
144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /client-react/src/components/ForumItem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Link } from "react-router-dom"; 5 | 6 | export default class ForumItem extends React.Component { 7 | static ForumFragment = gql` 8 | fragment ForumItem_ForumFragment on Forum { 9 | nodeId 10 | id 11 | name 12 | description 13 | slug 14 | } 15 | `; 16 | 17 | static CurrentUserFragment = gql` 18 | fragment ForumItem_CurrentUserFragment on User { 19 | nodeId 20 | isAdmin 21 | } 22 | `; 23 | 24 | static propTypes = { 25 | forum: propType(ForumItem.ForumFragment), 26 | currentUser: propType(ForumItem.CurrentUserFragment), 27 | }; 28 | 29 | render() { 30 | const { forum, currentUser } = this.props; 31 | return ( 32 |
33 |

34 | {forum.name} 35 |

36 | {forum.description && ( 37 |
{forum.description}
38 | )} 39 | {currentUser && currentUser.isAdmin ? ( 40 |
[edit]
41 | ) : null} 42 |
43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client-react/src/components/ForumPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Link } from "react-router-dom"; 5 | import ForumItem from "./ForumItem"; 6 | import TopicItem from "./TopicItem"; 7 | import Main from "./Main"; 8 | import NotFound from "./NotFound"; 9 | import CreateNewTopicForm from "./CreateNewTopicForm"; 10 | 11 | export default class ForumPage extends React.Component { 12 | static QueryFragment = gql` 13 | fragment ForumPage_QueryFragment on Query { 14 | ...CreateNewTopicForm_QueryFragment 15 | currentUser { 16 | nodeId 17 | id 18 | isAdmin 19 | ...ForumItem_CurrentUserFragment 20 | } 21 | forum: forumBySlug(slug: $slug) { 22 | nodeId 23 | name 24 | topics { 25 | nodes { 26 | ...TopicItem_TopicFragment 27 | } 28 | } 29 | ...ForumItem_ForumFragment 30 | } 31 | } 32 | ${TopicItem.TopicFragment} 33 | ${ForumItem.ForumFragment} 34 | ${ForumItem.CurrentUserFragment} 35 | ${CreateNewTopicForm.QueryFragment} 36 | `; 37 | 38 | static propTypes = { 39 | data: propType(ForumPage.QueryFragment), 40 | }; 41 | 42 | render() { 43 | const { data } = this.props; 44 | const { loading, error, currentUser, forum } = data; 45 | if (loading) { 46 | return
Loading...
; 47 | } 48 | if (error) { 49 | return
Error {error.message}
; 50 | } 51 | if (!forum) { 52 | return ; 53 | } 54 | return ( 55 |
56 |
{forum.name}
57 |
58 | Welcome to {forum.name}! {forum.description} 59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {forum.topics.nodes.length ? ( 71 | forum.topics.nodes.map(node => ( 72 | 78 | )) 79 | ) : ( 80 | 81 | 95 | 96 | )} 97 | 98 |
TopicAuthorRepliesLast post
82 | There are no topics yet!{" "} 83 | {currentUser ? ( 84 | currentUser.isAdmin ? ( 85 | "Create one below..." 86 | ) : ( 87 | "Please check back later or contact an admin." 88 | ) 89 | ) : ( 90 | 91 | Perhaps you need to log in? 92 | 93 | )} 94 |
99 | {currentUser ? ( 100 |
101 |

Create new topic

102 | { 105 | // TODO: alter the cache 106 | data.refetch(); 107 | }} 108 | /> 109 |
110 | ) : null} 111 |
112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /client-react/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import logo from "../logo.svg"; 4 | import { Link } from "react-router-dom"; 5 | import { propType } from "graphql-anywhere"; 6 | import PropTypes from "prop-types"; 7 | 8 | export default class Header extends React.Component { 9 | static UserFragment = gql` 10 | fragment Header_UserFragment on User { 11 | nodeId 12 | id 13 | name 14 | isAdmin 15 | } 16 | `; 17 | 18 | static propTypes = { 19 | user: propType(Header.UserFragment), 20 | loading: PropTypes.bool, 21 | error: PropTypes.object, 22 | }; 23 | 24 | renderUser() { 25 | const { user, loading, error } = this.props; 26 | if (user) { 27 | const username = user.name || `User ${user.id}`; 28 | return ( 29 | 30 | Logged in as {user.isAdmin ? "administrator" : ""} {username};{" "} 31 | Log out 32 | 33 | ); 34 | } else if (loading) { 35 | return null; 36 | } else if (error) { 37 | return null; 38 | } else { 39 | return Log in; 40 | } 41 | } 42 | 43 | render() { 44 | return ( 45 |
46 |
47 | 48 | logo 49 | PostGraphile Forum Demo 50 | 51 |
52 |
{this.renderUser()}
53 |
54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client-react/src/components/HomePage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Link } from "react-router-dom"; 5 | import ForumItem from "./ForumItem"; 6 | import Main from "./Main"; 7 | import CreateNewForumForm from "./CreateNewForumForm"; 8 | 9 | export default class HomePage extends React.Component { 10 | static QueryFragment = gql` 11 | fragment HomePage_QueryFragment on Query { 12 | ...CreateNewForumForm_QueryFragment 13 | currentUser { 14 | nodeId 15 | id 16 | isAdmin 17 | ...ForumItem_CurrentUserFragment 18 | } 19 | forums(first: 50) { 20 | nodes { 21 | nodeId 22 | ...ForumItem_ForumFragment 23 | } 24 | } 25 | } 26 | ${ForumItem.ForumFragment} 27 | ${ForumItem.CurrentUserFragment} 28 | ${CreateNewForumForm.QueryFragment} 29 | `; 30 | 31 | static propTypes = { 32 | data: propType(HomePage.QueryFragment), 33 | }; 34 | 35 | render() { 36 | const { data } = this.props; 37 | const { loading, error, currentUser, forums } = data; 38 | if (loading) { 39 | return
Loading...
; 40 | } 41 | if (error) { 42 | return
Error {error.message}
; 43 | } 44 | return ( 45 |
46 |

Welcome

47 |

48 | Welcome to the PostGraphile forum demo. Here you can see how we have 49 | harnessed the power of PostGraphile to quickly and easily make a 50 | simple forum.{" "} 51 | 52 | Take a look at the PostGraphile documentation 53 | {" "} 54 | to see how to get started with your own forum schema design. 55 |

56 |

Forum List

57 |
58 | {forums.nodes.length ? ( 59 | forums.nodes.map(node => ( 60 | 65 | )) 66 | ) : ( 67 |
68 | There are no forums yet!{" "} 69 | {currentUser ? ( 70 | currentUser.isAdmin ? ( 71 | "Create one below..." 72 | ) : ( 73 | "Please check back later or contact an admin." 74 | ) 75 | ) : ( 76 | 77 | Perhaps you need to log in? 78 | 79 | )} 80 |
81 | )} 82 |
83 | {currentUser && currentUser.isAdmin ? ( 84 |
85 |

Create new forum

86 |

Hello administrator! Would you like to create a new forum?

87 | { 90 | // TODO: alter the cache 91 | data.refetch(); 92 | }} 93 | /> 94 |
95 | ) : null} 96 |
97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client-react/src/components/LoginPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Redirect } from "react-router-dom"; 3 | import gql from "graphql-tag"; 4 | import { propType } from "graphql-anywhere"; 5 | import { Mutation } from "react-apollo"; 6 | import Main from "./Main"; 7 | 8 | const LOGIN = gql` 9 | mutation Login($username: String!, $password: String!) { 10 | login(input: { username: $username, password: $password }) { 11 | user { 12 | nodeId 13 | id 14 | username 15 | name 16 | } 17 | } 18 | } 19 | `; 20 | 21 | export default class LoginPage extends React.Component { 22 | static QueryFragment = gql` 23 | fragment LoginPage_QueryFragment on Query { 24 | currentUser { 25 | nodeId 26 | } 27 | } 28 | `; 29 | 30 | static propTypes = { 31 | data: propType(LoginPage.QueryFragment), 32 | }; 33 | 34 | state = { 35 | username: "", 36 | password: "", 37 | loggingIn: false, 38 | }; 39 | 40 | handleUsernameChange = e => { 41 | this.setState({ username: e.target.value, error: null }); 42 | }; 43 | 44 | handlePasswordChange = e => { 45 | this.setState({ password: e.target.value, error: null }); 46 | }; 47 | 48 | handleSubmitWith = login => async e => { 49 | e.preventDefault(); 50 | const { username, password } = this.state; 51 | this.setState({ loggingIn: true }); 52 | try { 53 | const { data } = await login({ variables: { username, password } }); 54 | if (data.login && data.login.user) { 55 | this.setState({ loggingIn: false, loggedInAs: data.login.user }); 56 | } else { 57 | throw new Error("Login failed"); 58 | } 59 | } catch (e) { 60 | this.setState({ 61 | loggingIn: false, 62 | error: "Login failed", 63 | }); 64 | } 65 | }; 66 | 67 | render() { 68 | const { loading, error, currentUser } = this.props.data; 69 | if (loading) { 70 | return
Loading...
; 71 | } 72 | if (error) { 73 | return
Error {error.message}
; 74 | } 75 | if (currentUser || this.state.loggedInAs) { 76 | return ; 77 | } else { 78 | return ( 79 |
80 |

Log in

81 | 84 |

Log in with email

85 | 86 | {login => ( 87 |
88 | 89 | 90 | 91 | 92 | 99 | 100 | 101 | 102 | 109 | 110 | 111 |
Username / email: 93 | 98 |
Password: 103 | 108 |
112 | {this.state.error ?

{this.state.error}

: null} 113 | 123 |
124 | )} 125 |
126 |
127 | ); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /client-react/src/components/Main.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const Main = ({ children }) =>
{children}
; 5 | Main.propTypes = { 6 | children: PropTypes.node, 7 | }; 8 | export default Main; 9 | -------------------------------------------------------------------------------- /client-react/src/components/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Main from "./Main"; 3 | import { Link } from "react-router-dom"; 4 | 5 | export default class NotFound extends React.Component { 6 | render() { 7 | return ( 8 |
9 |

Page not found!

10 |

11 | Return home 12 |

13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client-react/src/components/PostItem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import moment from "moment"; 5 | 6 | export default class PostItem extends React.Component { 7 | static PostFragment = gql` 8 | fragment PostItem_PostFragment on Post { 9 | nodeId 10 | id 11 | body 12 | createdAt 13 | user: author { 14 | id 15 | avatarUrl 16 | name 17 | } 18 | } 19 | `; 20 | 21 | static propTypes = { 22 | post: propType(PostItem.PostFragment), 23 | }; 24 | 25 | render() { 26 | const { post } = this.props; 27 | const { user, createdAt, body } = post; 28 | 29 | return ( 30 |
31 |
32 | 33 | {user.name} 34 |
35 |
36 | 37 |

{body}

38 |
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client-react/src/components/TopicItem.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Link } from "react-router-dom"; 5 | import moment from "moment"; 6 | 7 | export default class TopicItem extends React.Component { 8 | static TopicFragment = gql` 9 | fragment TopicItem_TopicFragment on Topic { 10 | nodeId 11 | id 12 | title 13 | body 14 | user: author { 15 | nodeId 16 | avatarUrl 17 | name 18 | } 19 | posts { 20 | totalCount 21 | } 22 | updatedAt 23 | } 24 | `; 25 | 26 | static propTypes = { 27 | topic: propType(TopicItem.TopicFragment), 28 | }; 29 | 30 | render() { 31 | const { topic, forum } = this.props; 32 | 33 | return ( 34 | 35 | 36 | {topic.title} 37 | 38 | {topic.user.name} 39 | {topic.posts.totalCount} 40 | {moment(topic.updatedAt).calendar()} 41 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client-react/src/components/TopicPage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { propType } from "graphql-anywhere"; 4 | import { Link } from "react-router-dom"; 5 | import TopicItem from "./TopicItem"; 6 | import PostItem from "./PostItem"; 7 | import Main from "./Main"; 8 | import NotFound from "./NotFound"; 9 | import CreateNewReplyForm from "./CreateNewReplyForm"; 10 | import ForumItem from "./ForumItem"; 11 | import moment from "moment"; 12 | 13 | export default class TopicPage extends React.Component { 14 | static QueryFragment = gql` 15 | fragment TopicPage_QueryFragment on Query { 16 | ...CreateNewReplyForm_QueryFragment 17 | currentUser { 18 | nodeId 19 | id 20 | isAdmin 21 | ...ForumItem_CurrentUserFragment 22 | } 23 | topic: topicById(id: $topic) { 24 | ...TopicItem_TopicFragment 25 | createdAt 26 | forum { 27 | name 28 | slug 29 | } 30 | posts { 31 | nodes { 32 | ...PostItem_PostFragment 33 | } 34 | } 35 | } 36 | } 37 | ${TopicItem.TopicFragment} 38 | ${PostItem.PostFragment} 39 | ${ForumItem.CurrentUserFragment} 40 | ${CreateNewReplyForm.QueryFragment} 41 | `; 42 | 43 | static propTypes = { 44 | data: propType(TopicPage.QueryFragment), 45 | }; 46 | 47 | render() { 48 | const { data } = this.props; 49 | const { loading, error, currentUser, topic } = data; 50 | 51 | if (loading) { 52 | return
Loading...
; 53 | } 54 | if (error) { 55 | return
Error {error.message}
; 56 | } 57 | if (!topic) { 58 | return ; 59 | } 60 | return ( 61 |
62 |
63 | {topic.forum.name} 64 |
65 |

{topic.title}

66 |
67 |
68 |
69 | 74 | {topic.user.name} 75 |
76 |
77 | 80 |

{topic.body}

81 |
82 |
83 | {topic.posts.nodes.length ? ( 84 | topic.posts.nodes.map(node => ( 85 | 90 | )) 91 | ) : ( 92 |
93 | There are no replies yet!{" "} 94 | {currentUser ? ( 95 | currentUser.isAdmin ? ( 96 | "Create one below..." 97 | ) : ( 98 | "Please check back later or contact an admin." 99 | ) 100 | ) : ( 101 | 102 | Perhaps you need to log in? 103 | 104 | )} 105 |
106 | )} 107 |
108 | {currentUser ? ( 109 |
110 |

Reply to this topic

111 | { 114 | // TODO: alter the cache 115 | data.refetch(); 116 | }} 117 | /> 118 |
119 | ) : null} 120 |
121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /client-react/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { ApolloProvider } from "react-apollo"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import App from "./App"; 6 | import { unregister } from "./registerServiceWorker"; 7 | import makeClient from "./apolloClient"; 8 | 9 | const client = makeClient(); 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById("root") 18 | ); 19 | unregister(); 20 | -------------------------------------------------------------------------------- /client-react/src/layouts/StandardLayout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "../components/Header"; 3 | import { propType } from "graphql-anywhere"; 4 | import PropTypes from "prop-types"; 5 | 6 | import gql from "graphql-tag"; 7 | 8 | const Empty = () => ( 9 |
10 | No bodyComponent provided 11 |
12 | ); 13 | 14 | export default class StandardLayout extends React.Component { 15 | static QueryFragment = gql` 16 | fragment StandardLayout_QueryFragment on Query { 17 | currentUser { 18 | ...Header_UserFragment 19 | } 20 | } 21 | ${Header.UserFragment} 22 | `; 23 | 24 | static propTypes = { 25 | data: propType(StandardLayout.QueryFragment), 26 | bodyComponent: PropTypes.func, 27 | }; 28 | 29 | render() { 30 | const { data, bodyComponent: BodyComponent = Empty } = this.props; 31 | const { loading, error, currentUser } = data; 32 | 33 | return ( 34 |
35 |
36 | 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client-react/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client-react/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === "localhost" || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === "[::1]" || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener("load", () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | "This web app is being served cache-first by a service " + 44 | "worker. To learn more, visit https://goo.gl/SC7cgQ" 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === "installed") { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log("New content is available; please refresh."); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log("Content is cached for offline use."); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error("Error during service worker registration:", error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get("content-type").indexOf("javascript") === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | "No internet connection found. App is running in offline mode." 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ("serviceWorker" in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /client-react/src/routes/ForumRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Query } from "react-apollo"; 4 | import StandardLayout from "../layouts/StandardLayout"; 5 | import ForumPage from "../components/ForumPage"; 6 | 7 | const ForumQuery = gql` 8 | query ForumQuery($slug: String!) { 9 | ...StandardLayout_QueryFragment 10 | ...ForumPage_QueryFragment 11 | } 12 | ${StandardLayout.QueryFragment} 13 | ${ForumPage.QueryFragment} 14 | `; 15 | 16 | export default class ForumRoute extends React.Component { 17 | render() { 18 | const { 19 | params: { slug }, 20 | } = this.props.match; 21 | return ( 22 | 23 | {({ loading, error, refetch, data }) => ( 24 | 33 | )} 34 | 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client-react/src/routes/HomeRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Query } from "react-apollo"; 4 | import StandardLayout from "../layouts/StandardLayout"; 5 | import HomePage from "../components/HomePage"; 6 | 7 | const HomeRouteQuery = gql` 8 | query HomeRouteQuery { 9 | ...StandardLayout_QueryFragment 10 | ...HomePage_QueryFragment 11 | } 12 | ${StandardLayout.QueryFragment} 13 | ${HomePage.QueryFragment} 14 | `; 15 | 16 | export default class HomeRoute extends React.Component { 17 | render() { 18 | return ( 19 | 20 | {({ loading, error, refetch, data }) => ( 21 | 32 | )} 33 | 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client-react/src/routes/LoginRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Query } from "react-apollo"; 4 | import StandardLayout from "../layouts/StandardLayout"; 5 | import LoginPage from "../components/LoginPage"; 6 | 7 | const LoginQuery = gql` 8 | query LoginQuery { 9 | ...StandardLayout_QueryFragment 10 | ...LoginPage_QueryFragment 11 | } 12 | ${StandardLayout.QueryFragment} 13 | ${LoginPage.QueryFragment} 14 | `; 15 | 16 | export default class LoginRoute extends React.Component { 17 | render() { 18 | return ( 19 | 20 | {({ loading, error, data }) => ( 21 | 25 | )} 26 | 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /client-react/src/routes/NotFoundRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Query } from "react-apollo"; 4 | import StandardLayout from "../layouts/StandardLayout"; 5 | import NotFound from "../components/NotFound"; 6 | 7 | const NotFoundRouteQuery = gql` 8 | query NotFoundRouteQuery { 9 | ...StandardLayout_QueryFragment 10 | } 11 | ${StandardLayout.QueryFragment}, 12 | `; 13 | 14 | export default class NotFoundRoute extends React.Component { 15 | render() { 16 | return ( 17 | 18 | {({ loading, error, data }) => ( 19 | 23 | )} 24 | 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client-react/src/routes/TopicRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | import { Query } from "react-apollo"; 4 | import StandardLayout from "../layouts/StandardLayout"; 5 | import TopicPage from "../components/TopicPage"; 6 | 7 | const TopicQuery = gql` 8 | query TopicQuery($topic: Int!) { 9 | ...StandardLayout_QueryFragment 10 | ...TopicPage_QueryFragment 11 | } 12 | ${StandardLayout.QueryFragment} 13 | ${TopicPage.QueryFragment} 14 | `; 15 | 16 | export default class TopicRoute extends React.Component { 17 | render() { 18 | console.log(this.props); 19 | const { 20 | params: { topic }, 21 | } = this.props.match; 22 | return ( 23 | 24 | {({ loading, error, refetch, data }) => ( 25 | 34 | )} 35 | 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # Generated Data 2 | 3 | Normally you wouldn't track generated data in git; however we deliberately keep these resources under version control: 4 | 5 | - `schema.graphql` is compared during upgrades, migrations and pull requests so you can see what has changed and ensure there’s no accidental GraphQL regressions 6 | - `schema.sql` is kept for similar (but database) reasons, and to ensure that all developers are running the same version of the database without accidental differences caused by faulty migration hygeine 7 | - `schema.json` is used by various tooling (e.g. ESLint) to validate the GraphQL queries; technically we should probably just use schema.graphql for this 🤷‍♂️ 8 | -------------------------------------------------------------------------------- /data/schema.graphql: -------------------------------------------------------------------------------- 1 | """All input for the create `Forum` mutation.""" 2 | input CreateForumInput { 3 | """ 4 | An arbitrary string value with no semantic meaning. Will be included in the 5 | payload verbatim. May be used to track mutations by the client. 6 | """ 7 | clientMutationId: String 8 | 9 | """The `Forum` to be created by this mutation.""" 10 | forum: ForumInput! 11 | } 12 | 13 | """The output of our create `Forum` mutation.""" 14 | type CreateForumPayload { 15 | """ 16 | The exact same `clientMutationId` that was provided in the mutation input, 17 | unchanged and unused. May be used by a client to track mutations. 18 | """ 19 | clientMutationId: String 20 | 21 | """The `Forum` that was created by this mutation.""" 22 | forum: Forum 23 | 24 | """ 25 | Our root query field type. Allows us to run any query from our mutation payload. 26 | """ 27 | query: Query 28 | 29 | """An edge for our `Forum`. May be used by Relay 1.""" 30 | forumEdge( 31 | """The method to use when ordering `Forum`.""" 32 | orderBy: [ForumsOrderBy!] = PRIMARY_KEY_ASC 33 | ): ForumsEdge 34 | } 35 | 36 | """All input for the create `Post` mutation.""" 37 | input CreatePostInput { 38 | """ 39 | An arbitrary string value with no semantic meaning. Will be included in the 40 | payload verbatim. May be used to track mutations by the client. 41 | """ 42 | clientMutationId: String 43 | 44 | """The `Post` to be created by this mutation.""" 45 | post: PostInput! 46 | } 47 | 48 | """The output of our create `Post` mutation.""" 49 | type CreatePostPayload { 50 | """ 51 | The exact same `clientMutationId` that was provided in the mutation input, 52 | unchanged and unused. May be used by a client to track mutations. 53 | """ 54 | clientMutationId: String 55 | 56 | """The `Post` that was created by this mutation.""" 57 | post: Post 58 | 59 | """ 60 | Our root query field type. Allows us to run any query from our mutation payload. 61 | """ 62 | query: Query 63 | 64 | """Reads a single `Topic` that is related to this `Post`.""" 65 | topic: Topic 66 | 67 | """Reads a single `User` that is related to this `Post`.""" 68 | author: User 69 | 70 | """An edge for our `Post`. May be used by Relay 1.""" 71 | postEdge( 72 | """The method to use when ordering `Post`.""" 73 | orderBy: [PostsOrderBy!] = PRIMARY_KEY_ASC 74 | ): PostsEdge 75 | } 76 | 77 | """All input for the create `Topic` mutation.""" 78 | input CreateTopicInput { 79 | """ 80 | An arbitrary string value with no semantic meaning. Will be included in the 81 | payload verbatim. May be used to track mutations by the client. 82 | """ 83 | clientMutationId: String 84 | 85 | """The `Topic` to be created by this mutation.""" 86 | topic: TopicInput! 87 | } 88 | 89 | """The output of our create `Topic` mutation.""" 90 | type CreateTopicPayload { 91 | """ 92 | The exact same `clientMutationId` that was provided in the mutation input, 93 | unchanged and unused. May be used by a client to track mutations. 94 | """ 95 | clientMutationId: String 96 | 97 | """The `Topic` that was created by this mutation.""" 98 | topic: Topic 99 | 100 | """ 101 | Our root query field type. Allows us to run any query from our mutation payload. 102 | """ 103 | query: Query 104 | 105 | """Reads a single `Forum` that is related to this `Topic`.""" 106 | forum: Forum 107 | 108 | """Reads a single `User` that is related to this `Topic`.""" 109 | author: User 110 | 111 | """An edge for our `Topic`. May be used by Relay 1.""" 112 | topicEdge( 113 | """The method to use when ordering `Topic`.""" 114 | orderBy: [TopicsOrderBy!] = PRIMARY_KEY_ASC 115 | ): TopicsEdge 116 | } 117 | 118 | """All input for the create `UserEmail` mutation.""" 119 | input CreateUserEmailInput { 120 | """ 121 | An arbitrary string value with no semantic meaning. Will be included in the 122 | payload verbatim. May be used to track mutations by the client. 123 | """ 124 | clientMutationId: String 125 | 126 | """The `UserEmail` to be created by this mutation.""" 127 | userEmail: UserEmailInput! 128 | } 129 | 130 | """The output of our create `UserEmail` mutation.""" 131 | type CreateUserEmailPayload { 132 | """ 133 | The exact same `clientMutationId` that was provided in the mutation input, 134 | unchanged and unused. May be used by a client to track mutations. 135 | """ 136 | clientMutationId: String 137 | 138 | """The `UserEmail` that was created by this mutation.""" 139 | userEmail: UserEmail 140 | 141 | """ 142 | Our root query field type. Allows us to run any query from our mutation payload. 143 | """ 144 | query: Query 145 | 146 | """Reads a single `User` that is related to this `UserEmail`.""" 147 | user: User 148 | 149 | """An edge for our `UserEmail`. May be used by Relay 1.""" 150 | userEmailEdge( 151 | """The method to use when ordering `UserEmail`.""" 152 | orderBy: [UserEmailsOrderBy!] = PRIMARY_KEY_ASC 153 | ): UserEmailsEdge 154 | } 155 | 156 | """A location in a connection that can be used for resuming pagination.""" 157 | scalar Cursor 158 | 159 | """ 160 | A point in time as described by the [ISO 161 | 8601](https://en.wikipedia.org/wiki/ISO_8601) standard. May or may not include a timezone. 162 | """ 163 | scalar Datetime 164 | 165 | """All input for the `deleteForumById` mutation.""" 166 | input DeleteForumByIdInput { 167 | """ 168 | An arbitrary string value with no semantic meaning. Will be included in the 169 | payload verbatim. May be used to track mutations by the client. 170 | """ 171 | clientMutationId: String 172 | id: Int! 173 | } 174 | 175 | """All input for the `deleteForumBySlug` mutation.""" 176 | input DeleteForumBySlugInput { 177 | """ 178 | An arbitrary string value with no semantic meaning. Will be included in the 179 | payload verbatim. May be used to track mutations by the client. 180 | """ 181 | clientMutationId: String 182 | 183 | """An URL-safe alias for the `Forum`.""" 184 | slug: String! 185 | } 186 | 187 | """All input for the `deleteForum` mutation.""" 188 | input DeleteForumInput { 189 | """ 190 | An arbitrary string value with no semantic meaning. Will be included in the 191 | payload verbatim. May be used to track mutations by the client. 192 | """ 193 | clientMutationId: String 194 | 195 | """ 196 | The globally unique `ID` which will identify a single `Forum` to be deleted. 197 | """ 198 | nodeId: ID! 199 | } 200 | 201 | """The output of our delete `Forum` mutation.""" 202 | type DeleteForumPayload { 203 | """ 204 | The exact same `clientMutationId` that was provided in the mutation input, 205 | unchanged and unused. May be used by a client to track mutations. 206 | """ 207 | clientMutationId: String 208 | 209 | """The `Forum` that was deleted by this mutation.""" 210 | forum: Forum 211 | deletedForumId: ID 212 | 213 | """ 214 | Our root query field type. Allows us to run any query from our mutation payload. 215 | """ 216 | query: Query 217 | 218 | """An edge for our `Forum`. May be used by Relay 1.""" 219 | forumEdge( 220 | """The method to use when ordering `Forum`.""" 221 | orderBy: [ForumsOrderBy!] = PRIMARY_KEY_ASC 222 | ): ForumsEdge 223 | } 224 | 225 | """All input for the `deletePostById` mutation.""" 226 | input DeletePostByIdInput { 227 | """ 228 | An arbitrary string value with no semantic meaning. Will be included in the 229 | payload verbatim. May be used to track mutations by the client. 230 | """ 231 | clientMutationId: String 232 | id: Int! 233 | } 234 | 235 | """All input for the `deletePost` mutation.""" 236 | input DeletePostInput { 237 | """ 238 | An arbitrary string value with no semantic meaning. Will be included in the 239 | payload verbatim. May be used to track mutations by the client. 240 | """ 241 | clientMutationId: String 242 | 243 | """ 244 | The globally unique `ID` which will identify a single `Post` to be deleted. 245 | """ 246 | nodeId: ID! 247 | } 248 | 249 | """The output of our delete `Post` mutation.""" 250 | type DeletePostPayload { 251 | """ 252 | The exact same `clientMutationId` that was provided in the mutation input, 253 | unchanged and unused. May be used by a client to track mutations. 254 | """ 255 | clientMutationId: String 256 | 257 | """The `Post` that was deleted by this mutation.""" 258 | post: Post 259 | deletedPostId: ID 260 | 261 | """ 262 | Our root query field type. Allows us to run any query from our mutation payload. 263 | """ 264 | query: Query 265 | 266 | """Reads a single `Topic` that is related to this `Post`.""" 267 | topic: Topic 268 | 269 | """Reads a single `User` that is related to this `Post`.""" 270 | author: User 271 | 272 | """An edge for our `Post`. May be used by Relay 1.""" 273 | postEdge( 274 | """The method to use when ordering `Post`.""" 275 | orderBy: [PostsOrderBy!] = PRIMARY_KEY_ASC 276 | ): PostsEdge 277 | } 278 | 279 | """All input for the `deleteTopicById` mutation.""" 280 | input DeleteTopicByIdInput { 281 | """ 282 | An arbitrary string value with no semantic meaning. Will be included in the 283 | payload verbatim. May be used to track mutations by the client. 284 | """ 285 | clientMutationId: String 286 | id: Int! 287 | } 288 | 289 | """All input for the `deleteTopic` mutation.""" 290 | input DeleteTopicInput { 291 | """ 292 | An arbitrary string value with no semantic meaning. Will be included in the 293 | payload verbatim. May be used to track mutations by the client. 294 | """ 295 | clientMutationId: String 296 | 297 | """ 298 | The globally unique `ID` which will identify a single `Topic` to be deleted. 299 | """ 300 | nodeId: ID! 301 | } 302 | 303 | """The output of our delete `Topic` mutation.""" 304 | type DeleteTopicPayload { 305 | """ 306 | The exact same `clientMutationId` that was provided in the mutation input, 307 | unchanged and unused. May be used by a client to track mutations. 308 | """ 309 | clientMutationId: String 310 | 311 | """The `Topic` that was deleted by this mutation.""" 312 | topic: Topic 313 | deletedTopicId: ID 314 | 315 | """ 316 | Our root query field type. Allows us to run any query from our mutation payload. 317 | """ 318 | query: Query 319 | 320 | """Reads a single `Forum` that is related to this `Topic`.""" 321 | forum: Forum 322 | 323 | """Reads a single `User` that is related to this `Topic`.""" 324 | author: User 325 | 326 | """An edge for our `Topic`. May be used by Relay 1.""" 327 | topicEdge( 328 | """The method to use when ordering `Topic`.""" 329 | orderBy: [TopicsOrderBy!] = PRIMARY_KEY_ASC 330 | ): TopicsEdge 331 | } 332 | 333 | """All input for the `deleteUserAuthenticationById` mutation.""" 334 | input DeleteUserAuthenticationByIdInput { 335 | """ 336 | An arbitrary string value with no semantic meaning. Will be included in the 337 | payload verbatim. May be used to track mutations by the client. 338 | """ 339 | clientMutationId: String 340 | id: Int! 341 | } 342 | 343 | """ 344 | All input for the `deleteUserAuthenticationByServiceAndIdentifier` mutation. 345 | """ 346 | input DeleteUserAuthenticationByServiceAndIdentifierInput { 347 | """ 348 | An arbitrary string value with no semantic meaning. Will be included in the 349 | payload verbatim. May be used to track mutations by the client. 350 | """ 351 | clientMutationId: String 352 | 353 | """The login service used, e.g. `twitter` or `github`.""" 354 | service: String! 355 | 356 | """A unique identifier for the user within the login service.""" 357 | identifier: String! 358 | } 359 | 360 | """All input for the `deleteUserAuthentication` mutation.""" 361 | input DeleteUserAuthenticationInput { 362 | """ 363 | An arbitrary string value with no semantic meaning. Will be included in the 364 | payload verbatim. May be used to track mutations by the client. 365 | """ 366 | clientMutationId: String 367 | 368 | """ 369 | The globally unique `ID` which will identify a single `UserAuthentication` to be deleted. 370 | """ 371 | nodeId: ID! 372 | } 373 | 374 | """The output of our delete `UserAuthentication` mutation.""" 375 | type DeleteUserAuthenticationPayload { 376 | """ 377 | The exact same `clientMutationId` that was provided in the mutation input, 378 | unchanged and unused. May be used by a client to track mutations. 379 | """ 380 | clientMutationId: String 381 | 382 | """The `UserAuthentication` that was deleted by this mutation.""" 383 | userAuthentication: UserAuthentication 384 | deletedUserAuthenticationId: ID 385 | 386 | """ 387 | Our root query field type. Allows us to run any query from our mutation payload. 388 | """ 389 | query: Query 390 | 391 | """An edge for our `UserAuthentication`. May be used by Relay 1.""" 392 | userAuthenticationEdge( 393 | """The method to use when ordering `UserAuthentication`.""" 394 | orderBy: [UserAuthenticationsOrderBy!] = PRIMARY_KEY_ASC 395 | ): UserAuthenticationsEdge 396 | } 397 | 398 | """All input for the `deleteUserById` mutation.""" 399 | input DeleteUserByIdInput { 400 | """ 401 | An arbitrary string value with no semantic meaning. Will be included in the 402 | payload verbatim. May be used to track mutations by the client. 403 | """ 404 | clientMutationId: String 405 | 406 | """Unique identifier for the user.""" 407 | id: Int! 408 | } 409 | 410 | """All input for the `deleteUserByUsername` mutation.""" 411 | input DeleteUserByUsernameInput { 412 | """ 413 | An arbitrary string value with no semantic meaning. Will be included in the 414 | payload verbatim. May be used to track mutations by the client. 415 | """ 416 | clientMutationId: String 417 | 418 | """Public-facing username (or 'handle') of the user.""" 419 | username: String! 420 | } 421 | 422 | """All input for the `deleteUserEmailById` mutation.""" 423 | input DeleteUserEmailByIdInput { 424 | """ 425 | An arbitrary string value with no semantic meaning. Will be included in the 426 | payload verbatim. May be used to track mutations by the client. 427 | """ 428 | clientMutationId: String 429 | id: Int! 430 | } 431 | 432 | """All input for the `deleteUserEmailByUserIdAndEmail` mutation.""" 433 | input DeleteUserEmailByUserIdAndEmailInput { 434 | """ 435 | An arbitrary string value with no semantic meaning. Will be included in the 436 | payload verbatim. May be used to track mutations by the client. 437 | """ 438 | clientMutationId: String 439 | userId: Int! 440 | 441 | """The users email address, in `a@b.c` format.""" 442 | email: String! 443 | } 444 | 445 | """All input for the `deleteUserEmail` mutation.""" 446 | input DeleteUserEmailInput { 447 | """ 448 | An arbitrary string value with no semantic meaning. Will be included in the 449 | payload verbatim. May be used to track mutations by the client. 450 | """ 451 | clientMutationId: String 452 | 453 | """ 454 | The globally unique `ID` which will identify a single `UserEmail` to be deleted. 455 | """ 456 | nodeId: ID! 457 | } 458 | 459 | """The output of our delete `UserEmail` mutation.""" 460 | type DeleteUserEmailPayload { 461 | """ 462 | The exact same `clientMutationId` that was provided in the mutation input, 463 | unchanged and unused. May be used by a client to track mutations. 464 | """ 465 | clientMutationId: String 466 | 467 | """The `UserEmail` that was deleted by this mutation.""" 468 | userEmail: UserEmail 469 | deletedUserEmailId: ID 470 | 471 | """ 472 | Our root query field type. Allows us to run any query from our mutation payload. 473 | """ 474 | query: Query 475 | 476 | """Reads a single `User` that is related to this `UserEmail`.""" 477 | user: User 478 | 479 | """An edge for our `UserEmail`. May be used by Relay 1.""" 480 | userEmailEdge( 481 | """The method to use when ordering `UserEmail`.""" 482 | orderBy: [UserEmailsOrderBy!] = PRIMARY_KEY_ASC 483 | ): UserEmailsEdge 484 | } 485 | 486 | """All input for the `deleteUser` mutation.""" 487 | input DeleteUserInput { 488 | """ 489 | An arbitrary string value with no semantic meaning. Will be included in the 490 | payload verbatim. May be used to track mutations by the client. 491 | """ 492 | clientMutationId: String 493 | 494 | """ 495 | The globally unique `ID` which will identify a single `User` to be deleted. 496 | """ 497 | nodeId: ID! 498 | } 499 | 500 | """The output of our delete `User` mutation.""" 501 | type DeleteUserPayload { 502 | """ 503 | The exact same `clientMutationId` that was provided in the mutation input, 504 | unchanged and unused. May be used by a client to track mutations. 505 | """ 506 | clientMutationId: String 507 | 508 | """The `User` that was deleted by this mutation.""" 509 | user: User 510 | deletedUserId: ID 511 | 512 | """ 513 | Our root query field type. Allows us to run any query from our mutation payload. 514 | """ 515 | query: Query 516 | 517 | """An edge for our `User`. May be used by Relay 1.""" 518 | userEdge( 519 | """The method to use when ordering `User`.""" 520 | orderBy: [UsersOrderBy!] = PRIMARY_KEY_ASC 521 | ): UsersEdge 522 | } 523 | 524 | """All input for the `forgotPassword` mutation.""" 525 | input ForgotPasswordInput { 526 | """ 527 | An arbitrary string value with no semantic meaning. Will be included in the 528 | payload verbatim. May be used to track mutations by the client. 529 | """ 530 | clientMutationId: String 531 | email: String! 532 | } 533 | 534 | """The output of our `forgotPassword` mutation.""" 535 | type ForgotPasswordPayload { 536 | """ 537 | The exact same `clientMutationId` that was provided in the mutation input, 538 | unchanged and unused. May be used by a client to track mutations. 539 | """ 540 | clientMutationId: String 541 | success: Boolean 542 | 543 | """ 544 | Our root query field type. Allows us to run any query from our mutation payload. 545 | """ 546 | query: Query 547 | } 548 | 549 | """A subject-based grouping of topics and posts.""" 550 | type Forum implements Node { 551 | """ 552 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 553 | """ 554 | nodeId: ID! 555 | id: Int! 556 | 557 | """An URL-safe alias for the `Forum`.""" 558 | slug: String! 559 | 560 | """The name of the `Forum` (indicates its subject matter).""" 561 | name: String! 562 | 563 | """A brief description of the `Forum` including it's purpose.""" 564 | description: String! 565 | createdAt: Datetime! 566 | updatedAt: Datetime! 567 | 568 | """Reads and enables pagination through a set of `Topic`.""" 569 | topics( 570 | """Only read the first `n` values of the set.""" 571 | first: Int 572 | 573 | """Only read the last `n` values of the set.""" 574 | last: Int 575 | 576 | """ 577 | Skip the first `n` values from our `after` cursor, an alternative to cursor 578 | based pagination. May not be used with `last`. 579 | """ 580 | offset: Int 581 | 582 | """Read all values in the set before (above) this cursor.""" 583 | before: Cursor 584 | 585 | """Read all values in the set after (below) this cursor.""" 586 | after: Cursor 587 | 588 | """The method to use when ordering `Topic`.""" 589 | orderBy: [TopicsOrderBy!] = [PRIMARY_KEY_ASC] 590 | 591 | """ 592 | A condition to be used in determining which values should be returned by the collection. 593 | """ 594 | condition: TopicCondition 595 | ): TopicsConnection! 596 | } 597 | 598 | """ 599 | A condition to be used against `Forum` object types. All fields are tested for equality and combined with a logical ‘and.’ 600 | """ 601 | input ForumCondition { 602 | """Checks for equality with the object’s `id` field.""" 603 | id: Int 604 | 605 | """Checks for equality with the object’s `slug` field.""" 606 | slug: String 607 | 608 | """Checks for equality with the object’s `name` field.""" 609 | name: String 610 | 611 | """Checks for equality with the object’s `description` field.""" 612 | description: String 613 | 614 | """Checks for equality with the object’s `createdAt` field.""" 615 | createdAt: Datetime 616 | 617 | """Checks for equality with the object’s `updatedAt` field.""" 618 | updatedAt: Datetime 619 | } 620 | 621 | """An input for mutations affecting `Forum`""" 622 | input ForumInput { 623 | """An URL-safe alias for the `Forum`.""" 624 | slug: String! 625 | 626 | """The name of the `Forum` (indicates its subject matter).""" 627 | name: String! 628 | 629 | """A brief description of the `Forum` including it's purpose.""" 630 | description: String 631 | } 632 | 633 | """ 634 | Represents an update to a `Forum`. Fields that are set will be updated. 635 | """ 636 | input ForumPatch { 637 | """An URL-safe alias for the `Forum`.""" 638 | slug: String 639 | 640 | """The name of the `Forum` (indicates its subject matter).""" 641 | name: String 642 | 643 | """A brief description of the `Forum` including it's purpose.""" 644 | description: String 645 | } 646 | 647 | """A connection to a list of `Forum` values.""" 648 | type ForumsConnection { 649 | """A list of `Forum` objects.""" 650 | nodes: [Forum]! 651 | 652 | """ 653 | A list of edges which contains the `Forum` and cursor to aid in pagination. 654 | """ 655 | edges: [ForumsEdge!]! 656 | 657 | """Information to aid in pagination.""" 658 | pageInfo: PageInfo! 659 | 660 | """The count of *all* `Forum` you could get from the connection.""" 661 | totalCount: Int! 662 | } 663 | 664 | """A `Forum` edge in the connection.""" 665 | type ForumsEdge { 666 | """A cursor for use in pagination.""" 667 | cursor: Cursor 668 | 669 | """The `Forum` at the end of the edge.""" 670 | node: Forum 671 | } 672 | 673 | """Methods to use when ordering `Forum`.""" 674 | enum ForumsOrderBy { 675 | NATURAL 676 | ID_ASC 677 | ID_DESC 678 | SLUG_ASC 679 | SLUG_DESC 680 | NAME_ASC 681 | NAME_DESC 682 | DESCRIPTION_ASC 683 | DESCRIPTION_DESC 684 | CREATED_AT_ASC 685 | CREATED_AT_DESC 686 | UPDATED_AT_ASC 687 | UPDATED_AT_DESC 688 | PRIMARY_KEY_ASC 689 | PRIMARY_KEY_DESC 690 | } 691 | 692 | """ 693 | The root mutation type which contains root level fields which mutate data. 694 | """ 695 | type Mutation { 696 | """Creates a single `Forum`.""" 697 | createForum( 698 | """ 699 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 700 | """ 701 | input: CreateForumInput! 702 | ): CreateForumPayload 703 | 704 | """Creates a single `Post`.""" 705 | createPost( 706 | """ 707 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 708 | """ 709 | input: CreatePostInput! 710 | ): CreatePostPayload 711 | 712 | """Creates a single `Topic`.""" 713 | createTopic( 714 | """ 715 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 716 | """ 717 | input: CreateTopicInput! 718 | ): CreateTopicPayload 719 | 720 | """Creates a single `UserEmail`.""" 721 | createUserEmail( 722 | """ 723 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 724 | """ 725 | input: CreateUserEmailInput! 726 | ): CreateUserEmailPayload 727 | 728 | """Updates a single `Forum` using its globally unique id and a patch.""" 729 | updateForum( 730 | """ 731 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 732 | """ 733 | input: UpdateForumInput! 734 | ): UpdateForumPayload 735 | 736 | """Updates a single `Forum` using a unique key and a patch.""" 737 | updateForumById( 738 | """ 739 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 740 | """ 741 | input: UpdateForumByIdInput! 742 | ): UpdateForumPayload 743 | 744 | """Updates a single `Forum` using a unique key and a patch.""" 745 | updateForumBySlug( 746 | """ 747 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 748 | """ 749 | input: UpdateForumBySlugInput! 750 | ): UpdateForumPayload 751 | 752 | """Updates a single `Post` using its globally unique id and a patch.""" 753 | updatePost( 754 | """ 755 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 756 | """ 757 | input: UpdatePostInput! 758 | ): UpdatePostPayload 759 | 760 | """Updates a single `Post` using a unique key and a patch.""" 761 | updatePostById( 762 | """ 763 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 764 | """ 765 | input: UpdatePostByIdInput! 766 | ): UpdatePostPayload 767 | 768 | """Updates a single `Topic` using its globally unique id and a patch.""" 769 | updateTopic( 770 | """ 771 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 772 | """ 773 | input: UpdateTopicInput! 774 | ): UpdateTopicPayload 775 | 776 | """Updates a single `Topic` using a unique key and a patch.""" 777 | updateTopicById( 778 | """ 779 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 780 | """ 781 | input: UpdateTopicByIdInput! 782 | ): UpdateTopicPayload 783 | 784 | """Updates a single `User` using its globally unique id and a patch.""" 785 | updateUser( 786 | """ 787 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 788 | """ 789 | input: UpdateUserInput! 790 | ): UpdateUserPayload 791 | 792 | """Updates a single `User` using a unique key and a patch.""" 793 | updateUserById( 794 | """ 795 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 796 | """ 797 | input: UpdateUserByIdInput! 798 | ): UpdateUserPayload 799 | 800 | """Updates a single `User` using a unique key and a patch.""" 801 | updateUserByUsername( 802 | """ 803 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 804 | """ 805 | input: UpdateUserByUsernameInput! 806 | ): UpdateUserPayload 807 | 808 | """Deletes a single `Forum` using its globally unique id.""" 809 | deleteForum( 810 | """ 811 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 812 | """ 813 | input: DeleteForumInput! 814 | ): DeleteForumPayload 815 | 816 | """Deletes a single `Forum` using a unique key.""" 817 | deleteForumById( 818 | """ 819 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 820 | """ 821 | input: DeleteForumByIdInput! 822 | ): DeleteForumPayload 823 | 824 | """Deletes a single `Forum` using a unique key.""" 825 | deleteForumBySlug( 826 | """ 827 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 828 | """ 829 | input: DeleteForumBySlugInput! 830 | ): DeleteForumPayload 831 | 832 | """Deletes a single `Post` using its globally unique id.""" 833 | deletePost( 834 | """ 835 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 836 | """ 837 | input: DeletePostInput! 838 | ): DeletePostPayload 839 | 840 | """Deletes a single `Post` using a unique key.""" 841 | deletePostById( 842 | """ 843 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 844 | """ 845 | input: DeletePostByIdInput! 846 | ): DeletePostPayload 847 | 848 | """Deletes a single `Topic` using its globally unique id.""" 849 | deleteTopic( 850 | """ 851 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 852 | """ 853 | input: DeleteTopicInput! 854 | ): DeleteTopicPayload 855 | 856 | """Deletes a single `Topic` using a unique key.""" 857 | deleteTopicById( 858 | """ 859 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 860 | """ 861 | input: DeleteTopicByIdInput! 862 | ): DeleteTopicPayload 863 | 864 | """Deletes a single `UserAuthentication` using its globally unique id.""" 865 | deleteUserAuthentication( 866 | """ 867 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 868 | """ 869 | input: DeleteUserAuthenticationInput! 870 | ): DeleteUserAuthenticationPayload 871 | 872 | """Deletes a single `UserAuthentication` using a unique key.""" 873 | deleteUserAuthenticationById( 874 | """ 875 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 876 | """ 877 | input: DeleteUserAuthenticationByIdInput! 878 | ): DeleteUserAuthenticationPayload 879 | 880 | """Deletes a single `UserAuthentication` using a unique key.""" 881 | deleteUserAuthenticationByServiceAndIdentifier( 882 | """ 883 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 884 | """ 885 | input: DeleteUserAuthenticationByServiceAndIdentifierInput! 886 | ): DeleteUserAuthenticationPayload 887 | 888 | """Deletes a single `UserEmail` using its globally unique id.""" 889 | deleteUserEmail( 890 | """ 891 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 892 | """ 893 | input: DeleteUserEmailInput! 894 | ): DeleteUserEmailPayload 895 | 896 | """Deletes a single `UserEmail` using a unique key.""" 897 | deleteUserEmailById( 898 | """ 899 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 900 | """ 901 | input: DeleteUserEmailByIdInput! 902 | ): DeleteUserEmailPayload 903 | 904 | """Deletes a single `UserEmail` using a unique key.""" 905 | deleteUserEmailByUserIdAndEmail( 906 | """ 907 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 908 | """ 909 | input: DeleteUserEmailByUserIdAndEmailInput! 910 | ): DeleteUserEmailPayload 911 | 912 | """Deletes a single `User` using its globally unique id.""" 913 | deleteUser( 914 | """ 915 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 916 | """ 917 | input: DeleteUserInput! 918 | ): DeleteUserPayload 919 | 920 | """Deletes a single `User` using a unique key.""" 921 | deleteUserById( 922 | """ 923 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 924 | """ 925 | input: DeleteUserByIdInput! 926 | ): DeleteUserPayload 927 | 928 | """Deletes a single `User` using a unique key.""" 929 | deleteUserByUsername( 930 | """ 931 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 932 | """ 933 | input: DeleteUserByUsernameInput! 934 | ): DeleteUserPayload 935 | 936 | """ 937 | If you've forgotten your password, give us one of your email addresses and we' 938 | send you a reset token. Note this only works if you have added an email address! 939 | """ 940 | forgotPassword( 941 | """ 942 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 943 | """ 944 | input: ForgotPasswordInput! 945 | ): ForgotPasswordPayload 946 | 947 | """ 948 | After triggering forgotPassword, you'll be sent a reset token. Combine this 949 | with your user ID and a new password to reset your password. 950 | """ 951 | resetPassword( 952 | """ 953 | The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. 954 | """ 955 | input: ResetPasswordInput! 956 | ): ResetPasswordPayload 957 | } 958 | 959 | """An object with a globally unique `ID`.""" 960 | interface Node { 961 | """ 962 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 963 | """ 964 | nodeId: ID! 965 | } 966 | 967 | """Information about pagination in a connection.""" 968 | type PageInfo { 969 | """When paginating forwards, are there more items?""" 970 | hasNextPage: Boolean! 971 | 972 | """When paginating backwards, are there more items?""" 973 | hasPreviousPage: Boolean! 974 | 975 | """When paginating backwards, the cursor to continue.""" 976 | startCursor: Cursor 977 | 978 | """When paginating forwards, the cursor to continue.""" 979 | endCursor: Cursor 980 | } 981 | 982 | """An individual message thread within a Forum.""" 983 | type Post implements Node { 984 | """ 985 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 986 | """ 987 | nodeId: ID! 988 | id: Int! 989 | topicId: Int! 990 | authorId: Int! 991 | 992 | """The body of the `Topic`, which Posts reply to.""" 993 | body: String! 994 | createdAt: Datetime! 995 | updatedAt: Datetime! 996 | 997 | """Reads a single `Topic` that is related to this `Post`.""" 998 | topic: Topic 999 | 1000 | """Reads a single `User` that is related to this `Post`.""" 1001 | author: User 1002 | } 1003 | 1004 | """ 1005 | A condition to be used against `Post` object types. All fields are tested for equality and combined with a logical ‘and.’ 1006 | """ 1007 | input PostCondition { 1008 | """Checks for equality with the object’s `id` field.""" 1009 | id: Int 1010 | 1011 | """Checks for equality with the object’s `topicId` field.""" 1012 | topicId: Int 1013 | 1014 | """Checks for equality with the object’s `authorId` field.""" 1015 | authorId: Int 1016 | 1017 | """Checks for equality with the object’s `body` field.""" 1018 | body: String 1019 | 1020 | """Checks for equality with the object’s `createdAt` field.""" 1021 | createdAt: Datetime 1022 | 1023 | """Checks for equality with the object’s `updatedAt` field.""" 1024 | updatedAt: Datetime 1025 | } 1026 | 1027 | """An input for mutations affecting `Post`""" 1028 | input PostInput { 1029 | topicId: Int! 1030 | 1031 | """The body of the `Topic`, which Posts reply to.""" 1032 | body: String 1033 | } 1034 | 1035 | """ 1036 | Represents an update to a `Post`. Fields that are set will be updated. 1037 | """ 1038 | input PostPatch { 1039 | """The body of the `Topic`, which Posts reply to.""" 1040 | body: String 1041 | } 1042 | 1043 | """A connection to a list of `Post` values.""" 1044 | type PostsConnection { 1045 | """A list of `Post` objects.""" 1046 | nodes: [Post]! 1047 | 1048 | """ 1049 | A list of edges which contains the `Post` and cursor to aid in pagination. 1050 | """ 1051 | edges: [PostsEdge!]! 1052 | 1053 | """Information to aid in pagination.""" 1054 | pageInfo: PageInfo! 1055 | 1056 | """The count of *all* `Post` you could get from the connection.""" 1057 | totalCount: Int! 1058 | } 1059 | 1060 | """A `Post` edge in the connection.""" 1061 | type PostsEdge { 1062 | """A cursor for use in pagination.""" 1063 | cursor: Cursor 1064 | 1065 | """The `Post` at the end of the edge.""" 1066 | node: Post 1067 | } 1068 | 1069 | """Methods to use when ordering `Post`.""" 1070 | enum PostsOrderBy { 1071 | NATURAL 1072 | ID_ASC 1073 | ID_DESC 1074 | TOPIC_ID_ASC 1075 | TOPIC_ID_DESC 1076 | AUTHOR_ID_ASC 1077 | AUTHOR_ID_DESC 1078 | BODY_ASC 1079 | BODY_DESC 1080 | CREATED_AT_ASC 1081 | CREATED_AT_DESC 1082 | UPDATED_AT_ASC 1083 | UPDATED_AT_DESC 1084 | PRIMARY_KEY_ASC 1085 | PRIMARY_KEY_DESC 1086 | } 1087 | 1088 | """The root query type which gives access points into the data universe.""" 1089 | type Query implements Node { 1090 | """ 1091 | Exposes the root query type nested one level down. This is helpful for Relay 1 1092 | which can only query top level fields if they are in a particular form. 1093 | """ 1094 | query: Query! 1095 | 1096 | """ 1097 | The root query type must be a `Node` to work well with Relay 1 mutations. This just resolves to `query`. 1098 | """ 1099 | nodeId: ID! 1100 | 1101 | """Fetches an object given its globally unique `ID`.""" 1102 | node( 1103 | """The globally unique `ID`.""" 1104 | nodeId: ID! 1105 | ): Node 1106 | 1107 | """Reads and enables pagination through a set of `Forum`.""" 1108 | forums( 1109 | """Only read the first `n` values of the set.""" 1110 | first: Int 1111 | 1112 | """Only read the last `n` values of the set.""" 1113 | last: Int 1114 | 1115 | """ 1116 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1117 | based pagination. May not be used with `last`. 1118 | """ 1119 | offset: Int 1120 | 1121 | """Read all values in the set before (above) this cursor.""" 1122 | before: Cursor 1123 | 1124 | """Read all values in the set after (below) this cursor.""" 1125 | after: Cursor 1126 | 1127 | """The method to use when ordering `Forum`.""" 1128 | orderBy: [ForumsOrderBy!] = [PRIMARY_KEY_ASC] 1129 | 1130 | """ 1131 | A condition to be used in determining which values should be returned by the collection. 1132 | """ 1133 | condition: ForumCondition 1134 | ): ForumsConnection 1135 | forumById(id: Int!): Forum 1136 | forumBySlug(slug: String!): Forum 1137 | postById(id: Int!): Post 1138 | topicById(id: Int!): Topic 1139 | userAuthenticationById(id: Int!): UserAuthentication 1140 | userAuthenticationByServiceAndIdentifier(service: String!, identifier: String!): UserAuthentication 1141 | userEmailById(id: Int!): UserEmail 1142 | userEmailByUserIdAndEmail(userId: Int!, email: String!): UserEmail 1143 | userById(id: Int!): User 1144 | userByUsername(username: String!): User 1145 | currentUser: User 1146 | 1147 | """Reads and enables pagination through a set of `Forum`.""" 1148 | forumsAboutCats( 1149 | """Only read the first `n` values of the set.""" 1150 | first: Int 1151 | 1152 | """Only read the last `n` values of the set.""" 1153 | last: Int 1154 | 1155 | """ 1156 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1157 | based pagination. May not be used with `last`. 1158 | """ 1159 | offset: Int 1160 | 1161 | """Read all values in the set before (above) this cursor.""" 1162 | before: Cursor 1163 | 1164 | """Read all values in the set after (below) this cursor.""" 1165 | after: Cursor 1166 | ): ForumsConnection! 1167 | 1168 | """Chosen by fair dice roll. Guaranteed to be random. XKCD#221""" 1169 | randomNumber: Int 1170 | 1171 | """Reads a single `Forum` using its globally unique `ID`.""" 1172 | forum( 1173 | """The globally unique `ID` to be used in selecting a single `Forum`.""" 1174 | nodeId: ID! 1175 | ): Forum 1176 | 1177 | """Reads a single `Post` using its globally unique `ID`.""" 1178 | post( 1179 | """The globally unique `ID` to be used in selecting a single `Post`.""" 1180 | nodeId: ID! 1181 | ): Post 1182 | 1183 | """Reads a single `Topic` using its globally unique `ID`.""" 1184 | topic( 1185 | """The globally unique `ID` to be used in selecting a single `Topic`.""" 1186 | nodeId: ID! 1187 | ): Topic 1188 | 1189 | """Reads a single `UserAuthentication` using its globally unique `ID`.""" 1190 | userAuthentication( 1191 | """ 1192 | The globally unique `ID` to be used in selecting a single `UserAuthentication`. 1193 | """ 1194 | nodeId: ID! 1195 | ): UserAuthentication 1196 | 1197 | """Reads a single `UserEmail` using its globally unique `ID`.""" 1198 | userEmail( 1199 | """ 1200 | The globally unique `ID` to be used in selecting a single `UserEmail`. 1201 | """ 1202 | nodeId: ID! 1203 | ): UserEmail 1204 | 1205 | """Reads a single `User` using its globally unique `ID`.""" 1206 | user( 1207 | """The globally unique `ID` to be used in selecting a single `User`.""" 1208 | nodeId: ID! 1209 | ): User 1210 | } 1211 | 1212 | """All input for the `resetPassword` mutation.""" 1213 | input ResetPasswordInput { 1214 | """ 1215 | An arbitrary string value with no semantic meaning. Will be included in the 1216 | payload verbatim. May be used to track mutations by the client. 1217 | """ 1218 | clientMutationId: String 1219 | userId: Int! 1220 | resetToken: String! 1221 | newPassword: String! 1222 | } 1223 | 1224 | """The output of our `resetPassword` mutation.""" 1225 | type ResetPasswordPayload { 1226 | """ 1227 | The exact same `clientMutationId` that was provided in the mutation input, 1228 | unchanged and unused. May be used by a client to track mutations. 1229 | """ 1230 | clientMutationId: String 1231 | user: User 1232 | 1233 | """ 1234 | Our root query field type. Allows us to run any query from our mutation payload. 1235 | """ 1236 | query: Query 1237 | 1238 | """An edge for our `User`. May be used by Relay 1.""" 1239 | userEdge( 1240 | """The method to use when ordering `User`.""" 1241 | orderBy: [UsersOrderBy!] = PRIMARY_KEY_ASC 1242 | ): UsersEdge 1243 | } 1244 | 1245 | """An individual message thread within a Forum.""" 1246 | type Topic implements Node { 1247 | """ 1248 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 1249 | """ 1250 | nodeId: ID! 1251 | id: Int! 1252 | forumId: Int! 1253 | authorId: Int! 1254 | 1255 | """The title of the `Topic`.""" 1256 | title: String! 1257 | 1258 | """The body of the `Topic`, which Posts reply to.""" 1259 | body: String! 1260 | createdAt: Datetime! 1261 | updatedAt: Datetime! 1262 | 1263 | """Reads a single `Forum` that is related to this `Topic`.""" 1264 | forum: Forum 1265 | 1266 | """Reads a single `User` that is related to this `Topic`.""" 1267 | author: User 1268 | 1269 | """Reads and enables pagination through a set of `Post`.""" 1270 | posts( 1271 | """Only read the first `n` values of the set.""" 1272 | first: Int 1273 | 1274 | """Only read the last `n` values of the set.""" 1275 | last: Int 1276 | 1277 | """ 1278 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1279 | based pagination. May not be used with `last`. 1280 | """ 1281 | offset: Int 1282 | 1283 | """Read all values in the set before (above) this cursor.""" 1284 | before: Cursor 1285 | 1286 | """Read all values in the set after (below) this cursor.""" 1287 | after: Cursor 1288 | 1289 | """The method to use when ordering `Post`.""" 1290 | orderBy: [PostsOrderBy!] = [PRIMARY_KEY_ASC] 1291 | 1292 | """ 1293 | A condition to be used in determining which values should be returned by the collection. 1294 | """ 1295 | condition: PostCondition 1296 | ): PostsConnection! 1297 | bodySummary(maxLength: Int): String 1298 | } 1299 | 1300 | """ 1301 | A condition to be used against `Topic` object types. All fields are tested for equality and combined with a logical ‘and.’ 1302 | """ 1303 | input TopicCondition { 1304 | """Checks for equality with the object’s `id` field.""" 1305 | id: Int 1306 | 1307 | """Checks for equality with the object’s `forumId` field.""" 1308 | forumId: Int 1309 | 1310 | """Checks for equality with the object’s `authorId` field.""" 1311 | authorId: Int 1312 | 1313 | """Checks for equality with the object’s `title` field.""" 1314 | title: String 1315 | 1316 | """Checks for equality with the object’s `body` field.""" 1317 | body: String 1318 | 1319 | """Checks for equality with the object’s `createdAt` field.""" 1320 | createdAt: Datetime 1321 | 1322 | """Checks for equality with the object’s `updatedAt` field.""" 1323 | updatedAt: Datetime 1324 | } 1325 | 1326 | """An input for mutations affecting `Topic`""" 1327 | input TopicInput { 1328 | forumId: Int! 1329 | 1330 | """The title of the `Topic`.""" 1331 | title: String! 1332 | 1333 | """The body of the `Topic`, which Posts reply to.""" 1334 | body: String 1335 | } 1336 | 1337 | """ 1338 | Represents an update to a `Topic`. Fields that are set will be updated. 1339 | """ 1340 | input TopicPatch { 1341 | """The title of the `Topic`.""" 1342 | title: String 1343 | 1344 | """The body of the `Topic`, which Posts reply to.""" 1345 | body: String 1346 | } 1347 | 1348 | """A connection to a list of `Topic` values.""" 1349 | type TopicsConnection { 1350 | """A list of `Topic` objects.""" 1351 | nodes: [Topic]! 1352 | 1353 | """ 1354 | A list of edges which contains the `Topic` and cursor to aid in pagination. 1355 | """ 1356 | edges: [TopicsEdge!]! 1357 | 1358 | """Information to aid in pagination.""" 1359 | pageInfo: PageInfo! 1360 | 1361 | """The count of *all* `Topic` you could get from the connection.""" 1362 | totalCount: Int! 1363 | } 1364 | 1365 | """A `Topic` edge in the connection.""" 1366 | type TopicsEdge { 1367 | """A cursor for use in pagination.""" 1368 | cursor: Cursor 1369 | 1370 | """The `Topic` at the end of the edge.""" 1371 | node: Topic 1372 | } 1373 | 1374 | """Methods to use when ordering `Topic`.""" 1375 | enum TopicsOrderBy { 1376 | NATURAL 1377 | ID_ASC 1378 | ID_DESC 1379 | FORUM_ID_ASC 1380 | FORUM_ID_DESC 1381 | AUTHOR_ID_ASC 1382 | AUTHOR_ID_DESC 1383 | TITLE_ASC 1384 | TITLE_DESC 1385 | BODY_ASC 1386 | BODY_DESC 1387 | CREATED_AT_ASC 1388 | CREATED_AT_DESC 1389 | UPDATED_AT_ASC 1390 | UPDATED_AT_DESC 1391 | PRIMARY_KEY_ASC 1392 | PRIMARY_KEY_DESC 1393 | } 1394 | 1395 | """All input for the `updateForumById` mutation.""" 1396 | input UpdateForumByIdInput { 1397 | """ 1398 | An arbitrary string value with no semantic meaning. Will be included in the 1399 | payload verbatim. May be used to track mutations by the client. 1400 | """ 1401 | clientMutationId: String 1402 | 1403 | """ 1404 | An object where the defined keys will be set on the `Forum` being updated. 1405 | """ 1406 | patch: ForumPatch! 1407 | id: Int! 1408 | } 1409 | 1410 | """All input for the `updateForumBySlug` mutation.""" 1411 | input UpdateForumBySlugInput { 1412 | """ 1413 | An arbitrary string value with no semantic meaning. Will be included in the 1414 | payload verbatim. May be used to track mutations by the client. 1415 | """ 1416 | clientMutationId: String 1417 | 1418 | """ 1419 | An object where the defined keys will be set on the `Forum` being updated. 1420 | """ 1421 | patch: ForumPatch! 1422 | 1423 | """An URL-safe alias for the `Forum`.""" 1424 | slug: String! 1425 | } 1426 | 1427 | """All input for the `updateForum` mutation.""" 1428 | input UpdateForumInput { 1429 | """ 1430 | An arbitrary string value with no semantic meaning. Will be included in the 1431 | payload verbatim. May be used to track mutations by the client. 1432 | """ 1433 | clientMutationId: String 1434 | 1435 | """ 1436 | The globally unique `ID` which will identify a single `Forum` to be updated. 1437 | """ 1438 | nodeId: ID! 1439 | 1440 | """ 1441 | An object where the defined keys will be set on the `Forum` being updated. 1442 | """ 1443 | patch: ForumPatch! 1444 | } 1445 | 1446 | """The output of our update `Forum` mutation.""" 1447 | type UpdateForumPayload { 1448 | """ 1449 | The exact same `clientMutationId` that was provided in the mutation input, 1450 | unchanged and unused. May be used by a client to track mutations. 1451 | """ 1452 | clientMutationId: String 1453 | 1454 | """The `Forum` that was updated by this mutation.""" 1455 | forum: Forum 1456 | 1457 | """ 1458 | Our root query field type. Allows us to run any query from our mutation payload. 1459 | """ 1460 | query: Query 1461 | 1462 | """An edge for our `Forum`. May be used by Relay 1.""" 1463 | forumEdge( 1464 | """The method to use when ordering `Forum`.""" 1465 | orderBy: [ForumsOrderBy!] = PRIMARY_KEY_ASC 1466 | ): ForumsEdge 1467 | } 1468 | 1469 | """All input for the `updatePostById` mutation.""" 1470 | input UpdatePostByIdInput { 1471 | """ 1472 | An arbitrary string value with no semantic meaning. Will be included in the 1473 | payload verbatim. May be used to track mutations by the client. 1474 | """ 1475 | clientMutationId: String 1476 | 1477 | """ 1478 | An object where the defined keys will be set on the `Post` being updated. 1479 | """ 1480 | patch: PostPatch! 1481 | id: Int! 1482 | } 1483 | 1484 | """All input for the `updatePost` mutation.""" 1485 | input UpdatePostInput { 1486 | """ 1487 | An arbitrary string value with no semantic meaning. Will be included in the 1488 | payload verbatim. May be used to track mutations by the client. 1489 | """ 1490 | clientMutationId: String 1491 | 1492 | """ 1493 | The globally unique `ID` which will identify a single `Post` to be updated. 1494 | """ 1495 | nodeId: ID! 1496 | 1497 | """ 1498 | An object where the defined keys will be set on the `Post` being updated. 1499 | """ 1500 | patch: PostPatch! 1501 | } 1502 | 1503 | """The output of our update `Post` mutation.""" 1504 | type UpdatePostPayload { 1505 | """ 1506 | The exact same `clientMutationId` that was provided in the mutation input, 1507 | unchanged and unused. May be used by a client to track mutations. 1508 | """ 1509 | clientMutationId: String 1510 | 1511 | """The `Post` that was updated by this mutation.""" 1512 | post: Post 1513 | 1514 | """ 1515 | Our root query field type. Allows us to run any query from our mutation payload. 1516 | """ 1517 | query: Query 1518 | 1519 | """Reads a single `Topic` that is related to this `Post`.""" 1520 | topic: Topic 1521 | 1522 | """Reads a single `User` that is related to this `Post`.""" 1523 | author: User 1524 | 1525 | """An edge for our `Post`. May be used by Relay 1.""" 1526 | postEdge( 1527 | """The method to use when ordering `Post`.""" 1528 | orderBy: [PostsOrderBy!] = PRIMARY_KEY_ASC 1529 | ): PostsEdge 1530 | } 1531 | 1532 | """All input for the `updateTopicById` mutation.""" 1533 | input UpdateTopicByIdInput { 1534 | """ 1535 | An arbitrary string value with no semantic meaning. Will be included in the 1536 | payload verbatim. May be used to track mutations by the client. 1537 | """ 1538 | clientMutationId: String 1539 | 1540 | """ 1541 | An object where the defined keys will be set on the `Topic` being updated. 1542 | """ 1543 | patch: TopicPatch! 1544 | id: Int! 1545 | } 1546 | 1547 | """All input for the `updateTopic` mutation.""" 1548 | input UpdateTopicInput { 1549 | """ 1550 | An arbitrary string value with no semantic meaning. Will be included in the 1551 | payload verbatim. May be used to track mutations by the client. 1552 | """ 1553 | clientMutationId: String 1554 | 1555 | """ 1556 | The globally unique `ID` which will identify a single `Topic` to be updated. 1557 | """ 1558 | nodeId: ID! 1559 | 1560 | """ 1561 | An object where the defined keys will be set on the `Topic` being updated. 1562 | """ 1563 | patch: TopicPatch! 1564 | } 1565 | 1566 | """The output of our update `Topic` mutation.""" 1567 | type UpdateTopicPayload { 1568 | """ 1569 | The exact same `clientMutationId` that was provided in the mutation input, 1570 | unchanged and unused. May be used by a client to track mutations. 1571 | """ 1572 | clientMutationId: String 1573 | 1574 | """The `Topic` that was updated by this mutation.""" 1575 | topic: Topic 1576 | 1577 | """ 1578 | Our root query field type. Allows us to run any query from our mutation payload. 1579 | """ 1580 | query: Query 1581 | 1582 | """Reads a single `Forum` that is related to this `Topic`.""" 1583 | forum: Forum 1584 | 1585 | """Reads a single `User` that is related to this `Topic`.""" 1586 | author: User 1587 | 1588 | """An edge for our `Topic`. May be used by Relay 1.""" 1589 | topicEdge( 1590 | """The method to use when ordering `Topic`.""" 1591 | orderBy: [TopicsOrderBy!] = PRIMARY_KEY_ASC 1592 | ): TopicsEdge 1593 | } 1594 | 1595 | """All input for the `updateUserById` mutation.""" 1596 | input UpdateUserByIdInput { 1597 | """ 1598 | An arbitrary string value with no semantic meaning. Will be included in the 1599 | payload verbatim. May be used to track mutations by the client. 1600 | """ 1601 | clientMutationId: String 1602 | 1603 | """ 1604 | An object where the defined keys will be set on the `User` being updated. 1605 | """ 1606 | patch: UserPatch! 1607 | 1608 | """Unique identifier for the user.""" 1609 | id: Int! 1610 | } 1611 | 1612 | """All input for the `updateUserByUsername` mutation.""" 1613 | input UpdateUserByUsernameInput { 1614 | """ 1615 | An arbitrary string value with no semantic meaning. Will be included in the 1616 | payload verbatim. May be used to track mutations by the client. 1617 | """ 1618 | clientMutationId: String 1619 | 1620 | """ 1621 | An object where the defined keys will be set on the `User` being updated. 1622 | """ 1623 | patch: UserPatch! 1624 | 1625 | """Public-facing username (or 'handle') of the user.""" 1626 | username: String! 1627 | } 1628 | 1629 | """All input for the `updateUser` mutation.""" 1630 | input UpdateUserInput { 1631 | """ 1632 | An arbitrary string value with no semantic meaning. Will be included in the 1633 | payload verbatim. May be used to track mutations by the client. 1634 | """ 1635 | clientMutationId: String 1636 | 1637 | """ 1638 | The globally unique `ID` which will identify a single `User` to be updated. 1639 | """ 1640 | nodeId: ID! 1641 | 1642 | """ 1643 | An object where the defined keys will be set on the `User` being updated. 1644 | """ 1645 | patch: UserPatch! 1646 | } 1647 | 1648 | """The output of our update `User` mutation.""" 1649 | type UpdateUserPayload { 1650 | """ 1651 | The exact same `clientMutationId` that was provided in the mutation input, 1652 | unchanged and unused. May be used by a client to track mutations. 1653 | """ 1654 | clientMutationId: String 1655 | 1656 | """The `User` that was updated by this mutation.""" 1657 | user: User 1658 | 1659 | """ 1660 | Our root query field type. Allows us to run any query from our mutation payload. 1661 | """ 1662 | query: Query 1663 | 1664 | """An edge for our `User`. May be used by Relay 1.""" 1665 | userEdge( 1666 | """The method to use when ordering `User`.""" 1667 | orderBy: [UsersOrderBy!] = PRIMARY_KEY_ASC 1668 | ): UsersEdge 1669 | } 1670 | 1671 | """A user who can log in to the application.""" 1672 | type User implements Node { 1673 | """ 1674 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 1675 | """ 1676 | nodeId: ID! 1677 | 1678 | """Unique identifier for the user.""" 1679 | id: Int! 1680 | 1681 | """Public-facing username (or 'handle') of the user.""" 1682 | username: String! 1683 | 1684 | """Public-facing name (or pseudonym) of the user.""" 1685 | name: String 1686 | 1687 | """Optional avatar URL.""" 1688 | avatarUrl: String 1689 | 1690 | """If true, the user has elevated privileges.""" 1691 | isAdmin: Boolean! 1692 | createdAt: Datetime! 1693 | updatedAt: Datetime! 1694 | 1695 | """Reads and enables pagination through a set of `UserEmail`.""" 1696 | userEmails( 1697 | """Only read the first `n` values of the set.""" 1698 | first: Int 1699 | 1700 | """Only read the last `n` values of the set.""" 1701 | last: Int 1702 | 1703 | """ 1704 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1705 | based pagination. May not be used with `last`. 1706 | """ 1707 | offset: Int 1708 | 1709 | """Read all values in the set before (above) this cursor.""" 1710 | before: Cursor 1711 | 1712 | """Read all values in the set after (below) this cursor.""" 1713 | after: Cursor 1714 | 1715 | """The method to use when ordering `UserEmail`.""" 1716 | orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] 1717 | 1718 | """ 1719 | A condition to be used in determining which values should be returned by the collection. 1720 | """ 1721 | condition: UserEmailCondition 1722 | ): UserEmailsConnection! 1723 | 1724 | """Reads and enables pagination through a set of `Topic`.""" 1725 | authoredTopics( 1726 | """Only read the first `n` values of the set.""" 1727 | first: Int 1728 | 1729 | """Only read the last `n` values of the set.""" 1730 | last: Int 1731 | 1732 | """ 1733 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1734 | based pagination. May not be used with `last`. 1735 | """ 1736 | offset: Int 1737 | 1738 | """Read all values in the set before (above) this cursor.""" 1739 | before: Cursor 1740 | 1741 | """Read all values in the set after (below) this cursor.""" 1742 | after: Cursor 1743 | 1744 | """The method to use when ordering `Topic`.""" 1745 | orderBy: [TopicsOrderBy!] = [PRIMARY_KEY_ASC] 1746 | 1747 | """ 1748 | A condition to be used in determining which values should be returned by the collection. 1749 | """ 1750 | condition: TopicCondition 1751 | ): TopicsConnection! 1752 | 1753 | """Reads and enables pagination through a set of `Post`.""" 1754 | authoredPosts( 1755 | """Only read the first `n` values of the set.""" 1756 | first: Int 1757 | 1758 | """Only read the last `n` values of the set.""" 1759 | last: Int 1760 | 1761 | """ 1762 | Skip the first `n` values from our `after` cursor, an alternative to cursor 1763 | based pagination. May not be used with `last`. 1764 | """ 1765 | offset: Int 1766 | 1767 | """Read all values in the set before (above) this cursor.""" 1768 | before: Cursor 1769 | 1770 | """Read all values in the set after (below) this cursor.""" 1771 | after: Cursor 1772 | 1773 | """The method to use when ordering `Post`.""" 1774 | orderBy: [PostsOrderBy!] = [PRIMARY_KEY_ASC] 1775 | 1776 | """ 1777 | A condition to be used in determining which values should be returned by the collection. 1778 | """ 1779 | condition: PostCondition 1780 | ): PostsConnection! 1781 | } 1782 | 1783 | """ 1784 | Contains information about the login providers this user has used, so that they may disconnect them should they wish. 1785 | """ 1786 | type UserAuthentication implements Node { 1787 | """ 1788 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 1789 | """ 1790 | nodeId: ID! 1791 | id: Int! 1792 | 1793 | """The login service used, e.g. `twitter` or `github`.""" 1794 | service: String! 1795 | 1796 | """A unique identifier for the user within the login service.""" 1797 | identifier: String! 1798 | createdAt: Datetime! 1799 | updatedAt: Datetime! 1800 | } 1801 | 1802 | """A `UserAuthentication` edge in the connection.""" 1803 | type UserAuthenticationsEdge { 1804 | """A cursor for use in pagination.""" 1805 | cursor: Cursor 1806 | 1807 | """The `UserAuthentication` at the end of the edge.""" 1808 | node: UserAuthentication 1809 | } 1810 | 1811 | """Methods to use when ordering `UserAuthentication`.""" 1812 | enum UserAuthenticationsOrderBy { 1813 | NATURAL 1814 | ID_ASC 1815 | ID_DESC 1816 | SERVICE_ASC 1817 | SERVICE_DESC 1818 | IDENTIFIER_ASC 1819 | IDENTIFIER_DESC 1820 | CREATED_AT_ASC 1821 | CREATED_AT_DESC 1822 | UPDATED_AT_ASC 1823 | UPDATED_AT_DESC 1824 | PRIMARY_KEY_ASC 1825 | PRIMARY_KEY_DESC 1826 | } 1827 | 1828 | """Information about a user's email address.""" 1829 | type UserEmail implements Node { 1830 | """ 1831 | A globally unique identifier. Can be used in various places throughout the system to identify this single value. 1832 | """ 1833 | nodeId: ID! 1834 | id: Int! 1835 | userId: Int! 1836 | 1837 | """The users email address, in `a@b.c` format.""" 1838 | email: String! 1839 | 1840 | """ 1841 | True if the user has is_verified their email address (by clicking the link in 1842 | the email we sent them, or logging in with a social login provider), false otherwise. 1843 | """ 1844 | isVerified: Boolean! 1845 | createdAt: Datetime! 1846 | updatedAt: Datetime! 1847 | 1848 | """Reads a single `User` that is related to this `UserEmail`.""" 1849 | user: User 1850 | } 1851 | 1852 | """ 1853 | A condition to be used against `UserEmail` object types. All fields are tested 1854 | for equality and combined with a logical ‘and.’ 1855 | """ 1856 | input UserEmailCondition { 1857 | """Checks for equality with the object’s `id` field.""" 1858 | id: Int 1859 | 1860 | """Checks for equality with the object’s `userId` field.""" 1861 | userId: Int 1862 | 1863 | """Checks for equality with the object’s `email` field.""" 1864 | email: String 1865 | 1866 | """Checks for equality with the object’s `isVerified` field.""" 1867 | isVerified: Boolean 1868 | 1869 | """Checks for equality with the object’s `createdAt` field.""" 1870 | createdAt: Datetime 1871 | 1872 | """Checks for equality with the object’s `updatedAt` field.""" 1873 | updatedAt: Datetime 1874 | } 1875 | 1876 | """An input for mutations affecting `UserEmail`""" 1877 | input UserEmailInput { 1878 | """The users email address, in `a@b.c` format.""" 1879 | email: String! 1880 | } 1881 | 1882 | """A connection to a list of `UserEmail` values.""" 1883 | type UserEmailsConnection { 1884 | """A list of `UserEmail` objects.""" 1885 | nodes: [UserEmail]! 1886 | 1887 | """ 1888 | A list of edges which contains the `UserEmail` and cursor to aid in pagination. 1889 | """ 1890 | edges: [UserEmailsEdge!]! 1891 | 1892 | """Information to aid in pagination.""" 1893 | pageInfo: PageInfo! 1894 | 1895 | """The count of *all* `UserEmail` you could get from the connection.""" 1896 | totalCount: Int! 1897 | } 1898 | 1899 | """A `UserEmail` edge in the connection.""" 1900 | type UserEmailsEdge { 1901 | """A cursor for use in pagination.""" 1902 | cursor: Cursor 1903 | 1904 | """The `UserEmail` at the end of the edge.""" 1905 | node: UserEmail 1906 | } 1907 | 1908 | """Methods to use when ordering `UserEmail`.""" 1909 | enum UserEmailsOrderBy { 1910 | NATURAL 1911 | ID_ASC 1912 | ID_DESC 1913 | USER_ID_ASC 1914 | USER_ID_DESC 1915 | EMAIL_ASC 1916 | EMAIL_DESC 1917 | IS_VERIFIED_ASC 1918 | IS_VERIFIED_DESC 1919 | CREATED_AT_ASC 1920 | CREATED_AT_DESC 1921 | UPDATED_AT_ASC 1922 | UPDATED_AT_DESC 1923 | PRIMARY_KEY_ASC 1924 | PRIMARY_KEY_DESC 1925 | } 1926 | 1927 | """ 1928 | Represents an update to a `User`. Fields that are set will be updated. 1929 | """ 1930 | input UserPatch { 1931 | """Public-facing name (or pseudonym) of the user.""" 1932 | name: String 1933 | 1934 | """Optional avatar URL.""" 1935 | avatarUrl: String 1936 | } 1937 | 1938 | """A `User` edge in the connection.""" 1939 | type UsersEdge { 1940 | """A cursor for use in pagination.""" 1941 | cursor: Cursor 1942 | 1943 | """The `User` at the end of the edge.""" 1944 | node: User 1945 | } 1946 | 1947 | """Methods to use when ordering `User`.""" 1948 | enum UsersOrderBy { 1949 | NATURAL 1950 | ID_ASC 1951 | ID_DESC 1952 | USERNAME_ASC 1953 | USERNAME_DESC 1954 | NAME_ASC 1955 | NAME_DESC 1956 | AVATAR_URL_ASC 1957 | AVATAR_URL_DESC 1958 | IS_ADMIN_ASC 1959 | IS_ADMIN_DESC 1960 | CREATED_AT_ASC 1961 | CREATED_AT_DESC 1962 | UPDATED_AT_ASC 1963 | UPDATED_AT_DESC 1964 | PRIMARY_KEY_ASC 1965 | PRIMARY_KEY_DESC 1966 | } 1967 | -------------------------------------------------------------------------------- /db/100_jobs.sql: -------------------------------------------------------------------------------- 1 | -- BEGIN: JOBS 2 | -- 3 | -- An asynchronous job queue schema for ACID compliant job creation through 4 | -- triggers/functions/etc. 5 | -- 6 | -- Worker code: worker.js 7 | -- 8 | -- Author: Benjie Gillam 9 | -- License: MIT 10 | -- URL: https://gist.github.com/benjie/839740697f5a1c46ee8da98a1efac218 11 | -- Donations: https://www.paypal.me/benjie 12 | 13 | CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; 14 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; 15 | 16 | CREATE SCHEMA IF NOT EXISTS app_jobs; 17 | 18 | CREATE TABLE app_jobs.job_queues ( 19 | queue_name varchar NOT NULL PRIMARY KEY, 20 | job_count int DEFAULT 0 NOT NULL, 21 | locked_at timestamp with time zone, 22 | locked_by varchar 23 | ); 24 | ALTER TABLE app_jobs.job_queues ENABLE ROW LEVEL SECURITY; 25 | 26 | CREATE TABLE app_jobs.jobs ( 27 | id serial PRIMARY KEY, 28 | queue_name varchar DEFAULT (public.gen_random_uuid())::varchar NOT NULL, 29 | task_identifier varchar NOT NULL, 30 | payload json DEFAULT '{}'::json NOT NULL, 31 | priority int DEFAULT 0 NOT NULL, 32 | run_at timestamp with time zone DEFAULT now() NOT NULL, 33 | attempts int DEFAULT 0 NOT NULL, 34 | last_error varchar, 35 | created_at timestamp with time zone NOT NULL DEFAULT NOW(), 36 | updated_at timestamp with time zone NOT NULL DEFAULT NOW() 37 | ); 38 | ALTER TABLE app_jobs.job_queues ENABLE ROW LEVEL SECURITY; 39 | 40 | CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $$ 41 | BEGIN 42 | PERFORM pg_notify(TG_ARGV[0], ''); 43 | RETURN NEW; 44 | END; 45 | $$ LANGUAGE plpgsql; 46 | 47 | CREATE FUNCTION app_jobs.update_timestamps() RETURNS trigger AS $$ 48 | BEGIN 49 | IF TG_OP = 'INSERT' THEN 50 | NEW.created_at = NOW(); 51 | NEW.updated_at = NOW(); 52 | ELSIF TG_OP = 'UPDATE' THEN 53 | NEW.created_at = OLD.created_at; 54 | NEW.updated_at = GREATEST(NOW(), OLD.updated_at + INTERVAL '1 millisecond'); 55 | END IF; 56 | RETURN NEW; 57 | END; 58 | $$ LANGUAGE plpgsql; 59 | 60 | CREATE FUNCTION app_jobs.jobs__decrease_job_queue_count() RETURNS trigger AS $$ 61 | BEGIN 62 | UPDATE app_jobs.job_queues 63 | SET job_count = job_queues.job_count - 1 64 | WHERE queue_name = OLD.queue_name 65 | AND job_queues.job_count > 1; 66 | 67 | IF NOT FOUND THEN 68 | DELETE FROM app_jobs.job_queues WHERE queue_name = OLD.queue_name; 69 | END IF; 70 | 71 | RETURN OLD; 72 | END; 73 | $$ LANGUAGE plpgsql; 74 | 75 | CREATE FUNCTION app_jobs.jobs__increase_job_queue_count() RETURNS trigger AS $$ 76 | BEGIN 77 | INSERT INTO app_jobs.job_queues(queue_name, job_count) 78 | VALUES(NEW.queue_name, 1) 79 | ON CONFLICT (queue_name) DO UPDATE SET job_count = job_queues.job_count + 1; 80 | 81 | RETURN NEW; 82 | END; 83 | $$ LANGUAGE plpgsql; 84 | 85 | CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.update_timestamps(); 86 | CREATE TRIGGER _500_increase_job_queue_count AFTER INSERT ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.jobs__increase_job_queue_count(); 87 | CREATE TRIGGER _500_decrease_job_queue_count BEFORE DELETE ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.jobs__decrease_job_queue_count(); 88 | CREATE TRIGGER _900_notify_worker AFTER INSERT ON app_jobs.jobs FOR EACH STATEMENT EXECUTE PROCEDURE app_jobs.do_notify('jobs:insert'); 89 | 90 | CREATE FUNCTION app_jobs.add_job(identifier varchar, payload json) RETURNS app_jobs.jobs AS $$ 91 | INSERT INTO app_jobs.jobs(task_identifier, payload) VALUES(identifier, payload) RETURNING *; 92 | $$ LANGUAGE sql; 93 | 94 | CREATE FUNCTION app_jobs.add_job(identifier varchar, queue_name varchar, payload json) RETURNS app_jobs.jobs AS $$ 95 | INSERT INTO app_jobs.jobs(task_identifier, queue_name, payload) VALUES(identifier, queue_name, payload) RETURNING *; 96 | $$ LANGUAGE sql; 97 | 98 | CREATE FUNCTION app_jobs.schedule_job(identifier varchar, queue_name varchar, payload json, run_at timestamptz) RETURNS app_jobs.jobs AS $$ 99 | INSERT INTO app_jobs.jobs(task_identifier, queue_name, payload, run_at) VALUES(identifier, queue_name, payload, run_at) RETURNING *; 100 | $$ LANGUAGE sql; 101 | 102 | CREATE FUNCTION app_jobs.complete_job(worker_id varchar, job_id int) RETURNS app_jobs.jobs AS $$ 103 | DECLARE 104 | v_row app_jobs.jobs; 105 | BEGIN 106 | DELETE FROM app_jobs.jobs 107 | WHERE id = job_id 108 | RETURNING * INTO v_row; 109 | 110 | UPDATE app_jobs.job_queues 111 | SET locked_by = null, locked_at = null 112 | WHERE queue_name = v_row.queue_name AND locked_by = worker_id; 113 | 114 | RETURN v_row; 115 | END; 116 | $$ LANGUAGE plpgsql; 117 | 118 | CREATE FUNCTION app_jobs.fail_job(worker_id varchar, job_id int, error_message varchar) RETURNS app_jobs.jobs AS $$ 119 | DECLARE 120 | v_row app_jobs.jobs; 121 | BEGIN 122 | UPDATE app_jobs.jobs 123 | SET 124 | last_error = error_message, 125 | run_at = greatest(now(), run_at) + (exp(least(attempts, 10))::text || ' seconds')::interval 126 | WHERE id = job_id 127 | RETURNING * INTO v_row; 128 | 129 | UPDATE app_jobs.job_queues 130 | SET locked_by = null, locked_at = null 131 | WHERE queue_name = v_row.queue_name AND locked_by = worker_id; 132 | 133 | RETURN v_row; 134 | END; 135 | $$ LANGUAGE plpgsql; 136 | 137 | CREATE FUNCTION app_jobs.get_job(worker_id varchar, identifiers varchar[]) RETURNS app_jobs.jobs AS $$ 138 | DECLARE 139 | v_job_id int; 140 | v_queue_name varchar; 141 | v_default_job_expiry text = (4 * 60 * 60)::text; 142 | v_default_job_maximum_attempts text = '25'; 143 | v_row app_jobs.jobs; 144 | BEGIN 145 | IF worker_id IS NULL OR length(worker_id) < 10 THEN 146 | RAISE EXCEPTION 'Invalid worker ID'; 147 | END IF; 148 | 149 | SELECT job_queues.queue_name, jobs.id INTO v_queue_name, v_job_id 150 | FROM app_jobs.job_queues 151 | INNER JOIN app_jobs.jobs USING (queue_name) 152 | WHERE (locked_at IS NULL OR locked_at < (now() - (COALESCE(current_setting('jobs.expiry', true), v_default_job_expiry) || ' seconds')::interval)) 153 | AND run_at <= now() 154 | AND attempts < COALESCE(current_setting('jobs.maximum_attempts', true), v_default_job_maximum_attempts)::int 155 | AND (identifiers IS NULL OR task_identifier = any(identifiers)) 156 | ORDER BY priority ASC, run_at ASC, id ASC 157 | LIMIT 1 158 | FOR UPDATE SKIP LOCKED; 159 | 160 | IF v_queue_name IS NULL THEN 161 | RETURN NULL; 162 | END IF; 163 | 164 | UPDATE app_jobs.job_queues 165 | SET 166 | locked_by = worker_id, 167 | locked_at = now() 168 | WHERE job_queues.queue_name = v_queue_name; 169 | 170 | UPDATE app_jobs.jobs 171 | SET attempts = attempts + 1 172 | WHERE id = v_job_id 173 | RETURNING * INTO v_row; 174 | 175 | RETURN v_row; 176 | END; 177 | $$ LANGUAGE plpgsql; 178 | 179 | -- END: JOBS 180 | -------------------------------------------------------------------------------- /db/200_schemas.sql: -------------------------------------------------------------------------------- 1 | create schema app_public; 2 | create schema app_private; 3 | 4 | grant usage on schema app_public to graphiledemo_visitor; 5 | 6 | -- This allows inserts without granting permission to the serial primary key column. 7 | alter default privileges for role graphiledemo in schema app_public grant usage, select on sequences to graphiledemo_visitor; 8 | -------------------------------------------------------------------------------- /db/300_utils.sql: -------------------------------------------------------------------------------- 1 | create function app_private.tg__add_job_for_row() returns trigger as $$ 2 | begin 3 | perform app_jobs.add_job(tg_argv[0], json_build_object('id', NEW.id)); 4 | return NEW; 5 | end; 6 | $$ language plpgsql set search_path from current; 7 | 8 | comment on function app_private.tg__add_job_for_row() is 9 | E'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record id will automatically be available on the JSON payload.'; 10 | 11 | -------------------------------------------------------------------------------- 12 | 13 | create function app_private.tg__update_timestamps() returns trigger as $$ 14 | begin 15 | NEW.created_at = (case when TG_OP = 'INSERT' then NOW() else OLD.created_at end); 16 | NEW.updated_at = (case when TG_OP = 'UPDATE' and OLD.updated_at >= NOW() then OLD.updated_at + interval '1 millisecond' else NOW() end); 17 | return NEW; 18 | end; 19 | $$ language plpgsql volatile set search_path from current; 20 | 21 | comment on function app_private.tg__update_timestamps() is 22 | E'This trigger should be called on all tables with created_at, updated_at - it ensures that they cannot be manipulated and that updated_at will always be larger than the previous updated_at.'; 23 | -------------------------------------------------------------------------------- /db/400_users.sql: -------------------------------------------------------------------------------- 1 | create function app_public.current_user_id() returns int as $$ 2 | select nullif(current_setting('jwt.claims.user_id', true), '')::int; 3 | $$ language sql stable set search_path from current; 4 | comment on function app_public.current_user_id() is 5 | E'@omit\nHandy method to get the current user ID for use in RLS policies, etc; in GraphQL, use `currentUser{id}` instead.'; 6 | 7 | -------------------------------------------------------------------------------- 8 | 9 | create table app_public.users ( 10 | id serial primary key, 11 | username citext not null unique check(username ~ '^[a-zA-Z]([a-zA-Z0-9][_]?)+$'), 12 | name text, 13 | avatar_url text check(avatar_url ~ '^https?://[^/]+'), 14 | is_admin boolean not null default false, 15 | created_at timestamptz not null default now(), 16 | updated_at timestamptz not null default now() 17 | ); 18 | alter table app_public.users enable row level security; 19 | 20 | create trigger _100_timestamps 21 | before insert or update on app_public.users 22 | for each row 23 | execute procedure app_private.tg__update_timestamps(); 24 | 25 | -- By doing `@omit all` we prevent the `allUsers` field from appearing in our 26 | -- GraphQL schema. User discovery is still possible by browsing the rest of 27 | -- the data, but it makes it harder for people to receive a `totalCount` of 28 | -- users, or enumerate them fully. 29 | comment on table app_public.users is 30 | E'@omit all\nA user who can log in to the application.'; 31 | 32 | comment on column app_public.users.id is 33 | E'Unique identifier for the user.'; 34 | comment on column app_public.users.username is 35 | E'Public-facing username (or ''handle'') of the user.'; 36 | comment on column app_public.users.name is 37 | E'Public-facing name (or pseudonym) of the user.'; 38 | comment on column app_public.users.avatar_url is 39 | E'Optional avatar URL.'; 40 | comment on column app_public.users.is_admin is 41 | E'If true, the user has elevated privileges.'; 42 | 43 | create policy select_all on app_public.users for select using (true); 44 | create policy update_self on app_public.users for update using (id = app_public.current_user_id()); 45 | create policy delete_self on app_public.users for delete using (id = app_public.current_user_id()); 46 | grant select on app_public.users to graphiledemo_visitor; 47 | grant update(name, avatar_url) on app_public.users to graphiledemo_visitor; 48 | grant delete on app_public.users to graphiledemo_visitor; 49 | 50 | create function app_private.tg_users__make_first_user_admin() returns trigger as $$ 51 | begin 52 | if not exists(select 1 from app_public.users) then 53 | NEW.is_admin = true; 54 | end if; 55 | return NEW; 56 | end; 57 | $$ language plpgsql volatile set search_path from current; 58 | create trigger _200_make_first_user_admin 59 | before insert on app_public.users 60 | for each row 61 | execute procedure app_private.tg_users__make_first_user_admin(); 62 | 63 | -------------------------------------------------------------------------------- 64 | 65 | create function app_public.current_user_is_admin() returns bool as $$ 66 | -- We're using exists here because it guarantees true/false rather than true/false/null 67 | select exists( 68 | select 1 from app_public.users where id = app_public.current_user_id() and is_admin = true 69 | ); 70 | $$ language sql stable set search_path from current; 71 | comment on function app_public.current_user_is_admin() is 72 | E'@omit\nHandy method to determine if the current user is an admin, for use in RLS policies, etc; in GraphQL should use `currentUser{isAdmin}` instead.'; 73 | 74 | -------------------------------------------------------------------------------- 75 | 76 | create function app_public.current_user() returns app_public.users as $$ 77 | select users.* from app_public.users where id = app_public.current_user_id(); 78 | $$ language sql stable set search_path from current; 79 | 80 | -------------------------------------------------------------------------------- 81 | 82 | create table app_private.user_secrets ( 83 | user_id int not null primary key references app_public.users, 84 | password_hash text, 85 | password_attempts int not null default 0, 86 | first_failed_password_attempt timestamptz, 87 | reset_password_token text, 88 | reset_password_token_generated timestamptz, 89 | reset_password_attempts int not null default 0, 90 | first_failed_reset_password_attempt timestamptz 91 | ); 92 | 93 | comment on table app_private.user_secrets is 94 | E'The contents of this table should never be visible to the user. Contains data mostly related to authentication.'; 95 | 96 | create function app_private.tg_user_secrets__insert_with_user() returns trigger as $$ 97 | begin 98 | insert into app_private.user_secrets(user_id) values(NEW.id); 99 | return NEW; 100 | end; 101 | $$ language plpgsql volatile set search_path from current; 102 | create trigger _500_insert_secrets 103 | after insert on app_public.users 104 | for each row 105 | execute procedure app_private.tg_user_secrets__insert_with_user(); 106 | 107 | comment on function app_private.tg_user_secrets__insert_with_user() is 108 | E'Ensures that every user record has an associated user_secret record.'; 109 | 110 | -------------------------------------------------------------------------------- 111 | 112 | create table app_public.user_emails ( 113 | id serial primary key, 114 | user_id int not null default app_public.current_user_id() references app_public.users on delete cascade, 115 | email citext not null check (email ~ '[^@]+@[^@]+\.[^@]+'), 116 | is_verified boolean not null default false, 117 | created_at timestamptz not null default now(), 118 | updated_at timestamptz not null default now(), 119 | unique(user_id, email) 120 | ); 121 | 122 | create unique index uniq_user_emails_verified_email on app_public.user_emails(email) where is_verified is true; 123 | alter table app_public.user_emails enable row level security; 124 | create trigger _100_timestamps 125 | before insert or update on app_public.user_emails 126 | for each row 127 | execute procedure app_private.tg__update_timestamps(); 128 | create trigger _900_send_verification_email 129 | after insert on app_public.user_emails 130 | for each row when (NEW.is_verified is false) 131 | execute procedure app_private.tg__add_job_for_row('user_emails__send_verification'); 132 | 133 | -- `@omit all` because there's no point exposing `allUserEmails` - you can only 134 | -- see your own, and having this behaviour can lead to bad practices from 135 | -- frontend teams. 136 | comment on table app_public.user_emails is 137 | E'@omit all\nInformation about a user''s email address.'; 138 | comment on column app_public.user_emails.email is 139 | E'The users email address, in `a@b.c` format.'; 140 | comment on column app_public.user_emails.is_verified is 141 | E'True if the user has is_verified their email address (by clicking the link in the email we sent them, or logging in with a social login provider), false otherwise.'; 142 | 143 | create policy select_own on app_public.user_emails for select using (user_id = app_public.current_user_id()); 144 | create policy insert_own on app_public.user_emails for insert with check (user_id = app_public.current_user_id()); 145 | create policy delete_own on app_public.user_emails for delete using (user_id = app_public.current_user_id()); -- TODO check this isn't the last one! 146 | grant select on app_public.user_emails to graphiledemo_visitor; 147 | grant insert (email) on app_public.user_emails to graphiledemo_visitor; 148 | grant delete on app_public.user_emails to graphiledemo_visitor; 149 | 150 | -------------------------------------------------------------------------------- 151 | 152 | create table app_private.user_email_secrets ( 153 | user_email_id int primary key references app_public.user_emails on delete cascade, 154 | verification_token text, 155 | password_reset_email_sent_at timestamptz 156 | ); 157 | alter table app_private.user_email_secrets enable row level security; 158 | 159 | comment on table app_private.user_email_secrets is 160 | E'The contents of this table should never be visible to the user. Contains data mostly related to email verification and avoiding spamming users.'; 161 | comment on column app_private.user_email_secrets.password_reset_email_sent_at is 162 | E'We store the time the last password reset was sent to this email to prevent the email getting flooded.'; 163 | 164 | create function app_private.tg_user_email_secrets__insert_with_user_email() returns trigger as $$ 165 | declare 166 | v_verification_token text; 167 | begin 168 | if NEW.is_verified is false then 169 | v_verification_token = encode(gen_random_bytes(4), 'hex'); 170 | end if; 171 | insert into app_private.user_email_secrets(user_email_id, verification_token) values(NEW.id, v_verification_token); 172 | return NEW; 173 | end; 174 | $$ language plpgsql volatile set search_path from current; 175 | create trigger _500_insert_secrets 176 | after insert on app_public.user_emails 177 | for each row 178 | execute procedure app_private.tg_user_email_secrets__insert_with_user_email(); 179 | comment on function app_private.tg_user_email_secrets__insert_with_user_email() is 180 | E'Ensures that every user_email record has an associated user_email_secret record.'; 181 | 182 | -------------------------------------------------------------------------------- 183 | 184 | create table app_public.user_authentications ( 185 | id serial primary key, 186 | user_id int not null references app_public.users on delete cascade, 187 | service text not null, 188 | identifier text not null, 189 | details jsonb not null default '{}'::jsonb, 190 | created_at timestamptz not null default now(), 191 | updated_at timestamptz not null default now(), 192 | constraint uniq_user_authentications unique(service, identifier) 193 | ); 194 | alter table app_public.user_authentications enable row level security; 195 | create trigger _100_timestamps 196 | before insert or update on app_public.user_authentications 197 | for each row 198 | execute procedure app_private.tg__update_timestamps(); 199 | 200 | comment on table app_public.user_authentications is 201 | E'@omit all\nContains information about the login providers this user has used, so that they may disconnect them should they wish.'; 202 | comment on column app_public.user_authentications.user_id is 203 | E'@omit'; 204 | comment on column app_public.user_authentications.service is 205 | E'The login service used, e.g. `twitter` or `github`.'; 206 | comment on column app_public.user_authentications.identifier is 207 | E'A unique identifier for the user within the login service.'; 208 | comment on column app_public.user_authentications.details is 209 | E'@omit\nAdditional profile details extracted from this login method'; 210 | 211 | create policy select_own on app_public.user_authentications for select using (user_id = app_public.current_user_id()); 212 | create policy delete_own on app_public.user_authentications for delete using (user_id = app_public.current_user_id()); -- TODO check this isn't the last one, or that they have a verified email address 213 | grant select on app_public.user_authentications to graphiledemo_visitor; 214 | grant delete on app_public.user_authentications to graphiledemo_visitor; 215 | 216 | -------------------------------------------------------------------------------- 217 | 218 | create table app_private.user_authentication_secrets ( 219 | user_authentication_id int not null primary key references app_public.user_authentications on delete cascade, 220 | details jsonb not null default '{}'::jsonb 221 | ); 222 | alter table app_private.user_authentication_secrets enable row level security; 223 | 224 | -- NOTE: user_authentication_secrets doesn't need an auto-inserter as we handle 225 | -- that everywhere that can create a user_authentication row. 226 | 227 | -------------------------------------------------------------------------------- 228 | 229 | create function app_public.forgot_password(email text) returns boolean as $$ 230 | declare 231 | v_user_email app_public.user_emails; 232 | v_reset_token text; 233 | v_reset_min_duration_between_emails interval = interval '30 minutes'; 234 | v_reset_max_duration interval = interval '3 days'; 235 | begin 236 | -- Find the matching user_email 237 | select user_emails.* into v_user_email 238 | from app_public.user_emails 239 | where user_emails.email = forgot_password.email::citext 240 | order by is_verified desc, id desc; 241 | 242 | if not (v_user_email is null) then 243 | -- See if we've triggered a reset recently 244 | if exists( 245 | select 1 246 | from app_private.user_email_secrets 247 | where user_email_id = v_user_email.id 248 | and password_reset_email_sent_at is not null 249 | and password_reset_email_sent_at > now() - v_reset_min_duration_between_emails 250 | ) then 251 | return true; 252 | end if; 253 | 254 | -- Fetch or generate reset token 255 | update app_private.user_secrets 256 | set 257 | reset_password_token = ( 258 | case 259 | when reset_password_token is null or reset_password_token_generated < NOW() - v_reset_max_duration 260 | then encode(gen_random_bytes(6), 'hex') 261 | else reset_password_token 262 | end 263 | ), 264 | reset_password_token_generated = ( 265 | case 266 | when reset_password_token is null or reset_password_token_generated < NOW() - v_reset_max_duration 267 | then now() 268 | else reset_password_token_generated 269 | end 270 | ) 271 | where user_id = v_user_email.user_id 272 | returning reset_password_token into v_reset_token; 273 | 274 | -- Don't allow spamming an email 275 | update app_private.user_email_secrets 276 | set password_reset_email_sent_at = now() 277 | where user_email_id = v_user_email.id; 278 | 279 | -- Trigger email send 280 | perform app_jobs.add_job('user__forgot_password', json_build_object('id', v_user_email.user_id, 'email', v_user_email.email::text, 'token', v_reset_token)); 281 | return true; 282 | 283 | end if; 284 | return false; 285 | end; 286 | $$ language plpgsql strict security definer volatile set search_path from current; 287 | 288 | comment on function app_public.forgot_password(email text) is 289 | E'@resultFieldName success\nIf you''ve forgotten your password, give us one of your email addresses and we'' send you a reset token. Note this only works if you have added an email address!'; 290 | 291 | -------------------------------------------------------------------------------- 292 | 293 | create function app_private.login(username text, password text) returns app_public.users as $$ 294 | declare 295 | v_user app_public.users; 296 | v_user_secret app_private.user_secrets; 297 | v_login_attempt_window_duration interval = interval '6 hours'; 298 | begin 299 | select users.* into v_user 300 | from app_public.users 301 | where 302 | -- Match username against users username, or any verified email address 303 | ( 304 | users.username = login.username 305 | or 306 | exists( 307 | select 1 308 | from app_public.user_emails 309 | where user_id = users.id 310 | and is_verified is true 311 | and email = login.username::citext 312 | ) 313 | ); 314 | 315 | if not (v_user is null) then 316 | -- Load their secrets 317 | select * into v_user_secret from app_private.user_secrets 318 | where user_secrets.user_id = v_user.id; 319 | 320 | -- Have there been too many login attempts? 321 | if ( 322 | v_user_secret.first_failed_password_attempt is not null 323 | and 324 | v_user_secret.first_failed_password_attempt > NOW() - v_login_attempt_window_duration 325 | and 326 | v_user_secret.password_attempts >= 20 327 | ) then 328 | raise exception 'User account locked - too many login attempts' using errcode = 'LOCKD'; 329 | end if; 330 | 331 | -- Not too many login attempts, let's check the password 332 | if v_user_secret.password_hash = crypt(password, v_user_secret.password_hash) then 333 | -- Excellent - they're loggged in! Let's reset the attempt tracking 334 | update app_private.user_secrets 335 | set password_attempts = 0, first_failed_password_attempt = null 336 | where user_id = v_user.id; 337 | return v_user; 338 | else 339 | -- Wrong password, bump all the attempt tracking figures 340 | update app_private.user_secrets 341 | set 342 | password_attempts = (case when first_failed_password_attempt is null or first_failed_password_attempt < now() - v_login_attempt_window_duration then 1 else password_attempts + 1 end), 343 | first_failed_password_attempt = (case when first_failed_password_attempt is null or first_failed_password_attempt < now() - v_login_attempt_window_duration then now() else first_failed_password_attempt end) 344 | where user_id = v_user.id; 345 | return null; 346 | end if; 347 | else 348 | -- No user with that email/username was found 349 | return null; 350 | end if; 351 | end; 352 | $$ language plpgsql strict security definer volatile set search_path from current; 353 | 354 | comment on function app_private.login(username text, password text) is 355 | E'Returns a user that matches the username/password combo, or null on failure.'; 356 | 357 | -------------------------------------------------------------------------------- 358 | 359 | create function app_public.reset_password(user_id int, reset_token text, new_password text) returns app_public.users as $$ 360 | declare 361 | v_user app_public.users; 362 | v_user_secret app_private.user_secrets; 363 | v_reset_max_duration interval = interval '3 days'; 364 | begin 365 | select users.* into v_user 366 | from app_public.users 367 | where id = user_id; 368 | 369 | if not (v_user is null) then 370 | -- Load their secrets 371 | select * into v_user_secret from app_private.user_secrets 372 | where user_secrets.user_id = v_user.id; 373 | 374 | -- Have there been too many reset attempts? 375 | if ( 376 | v_user_secret.first_failed_reset_password_attempt is not null 377 | and 378 | v_user_secret.first_failed_reset_password_attempt > NOW() - v_reset_max_duration 379 | and 380 | v_user_secret.reset_password_attempts >= 20 381 | ) then 382 | raise exception 'Password reset locked - too many reset attempts' using errcode = 'LOCKD'; 383 | end if; 384 | 385 | -- Not too many reset attempts, let's check the token 386 | if v_user_secret.reset_password_token = reset_token then 387 | -- Excellent - they're legit; let's reset the password as requested 388 | update app_private.user_secrets 389 | set 390 | password_hash = crypt(new_password, gen_salt('bf')), 391 | password_attempts = 0, 392 | first_failed_password_attempt = null, 393 | reset_password_token = null, 394 | reset_password_token_generated = null, 395 | reset_password_attempts = 0, 396 | first_failed_reset_password_attempt = null 397 | where user_secrets.user_id = v_user.id; 398 | return v_user; 399 | else 400 | -- Wrong token, bump all the attempt tracking figures 401 | update app_private.user_secrets 402 | set 403 | reset_password_attempts = (case when first_failed_reset_password_attempt is null or first_failed_reset_password_attempt < now() - v_reset_max_duration then 1 else reset_password_attempts + 1 end), 404 | first_failed_reset_password_attempt = (case when first_failed_reset_password_attempt is null or first_failed_reset_password_attempt < now() - v_reset_max_duration then now() else first_failed_reset_password_attempt end) 405 | where user_secrets.user_id = v_user.id; 406 | return null; 407 | end if; 408 | else 409 | -- No user with that id was found 410 | return null; 411 | end if; 412 | end; 413 | $$ language plpgsql strict volatile security definer set search_path from current; 414 | 415 | comment on function app_public.reset_password(user_id int, reset_token text, new_password text) is 416 | E'After triggering forgotPassword, you''ll be sent a reset token. Combine this with your user ID and a new password to reset your password.'; 417 | 418 | -------------------------------------------------------------------------------- 419 | 420 | 421 | create function app_private.really_create_user(username text, email text, email_is_verified bool, name text, avatar_url text, password text default null) returns app_public.users as $$ 422 | declare 423 | v_user app_public.users; 424 | v_username text = username; 425 | begin 426 | -- Sanitise the username, and make it unique if necessary. 427 | if v_username is null then 428 | v_username = coalesce(name, 'user'); 429 | end if; 430 | v_username = regexp_replace(v_username, '^[^a-z]+', '', 'i'); 431 | v_username = regexp_replace(v_username, '[^a-z0-9]+', '_', 'i'); 432 | if v_username is null or length(v_username) < 3 then 433 | v_username = 'user'; 434 | end if; 435 | select ( 436 | case 437 | when i = 0 then v_username 438 | else v_username || i::text 439 | end 440 | ) into v_username from generate_series(0, 1000) i 441 | where not exists( 442 | select 1 443 | from app_public.users 444 | where users.username = ( 445 | case 446 | when i = 0 then v_username 447 | else v_username || i::text 448 | end 449 | ) 450 | ) 451 | limit 1; 452 | 453 | -- Insert the new user 454 | insert into app_public.users (username, name, avatar_url) values 455 | (v_username, name, avatar_url) 456 | returning * into v_user; 457 | 458 | -- Add the user's email 459 | if email is not null then 460 | insert into app_public.user_emails (user_id, email, is_verified) 461 | values (v_user.id, email, email_is_verified); 462 | end if; 463 | 464 | -- Store the password 465 | if password is not null then 466 | update app_private.user_secrets 467 | set password_hash = crypt(password, gen_salt('bf')) 468 | where user_id = v_user.id; 469 | end if; 470 | 471 | return v_user; 472 | end; 473 | $$ language plpgsql volatile set search_path from current; 474 | 475 | comment on function app_private.really_create_user(username text, email text, email_is_verified bool, name text, avatar_url text, password text) is 476 | E'Creates a user account. All arguments are optional, it trusts the calling method to perform sanitisation.'; 477 | 478 | -------------------------------------------------------------------------------- 479 | 480 | create function app_private.register_user(f_service character varying, f_identifier character varying, f_profile json, f_auth_details json, f_email_is_verified boolean default false) returns app_public.users as $$ 481 | declare 482 | v_user app_public.users; 483 | v_email citext; 484 | v_name text; 485 | v_username text; 486 | v_avatar_url text; 487 | v_user_authentication_id int; 488 | begin 489 | -- Extract data from the user’s OAuth profile data. 490 | v_email := f_profile ->> 'email'; 491 | v_name := f_profile ->> 'name'; 492 | v_username := f_profile ->> 'username'; 493 | v_avatar_url := f_profile ->> 'avatar_url'; 494 | 495 | -- Create the user account 496 | v_user = app_private.really_create_user( 497 | username => v_username, 498 | email => v_email, 499 | email_is_verified => f_email_is_verified, 500 | name => v_name, 501 | avatar_url => v_avatar_url 502 | ); 503 | 504 | -- Insert the user’s private account data (e.g. OAuth tokens) 505 | insert into app_public.user_authentications (user_id, service, identifier, details) values 506 | (v_user.id, f_service, f_identifier, f_profile) returning id into v_user_authentication_id; 507 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values 508 | (v_user_authentication_id, f_auth_details); 509 | 510 | return v_user; 511 | end; 512 | $$ language plpgsql volatile security definer set search_path from current; 513 | 514 | comment on function app_private.register_user(f_service character varying, f_identifier character varying, f_profile json, f_auth_details json, f_email_is_verified boolean) is 515 | E'Used to register a user from information gleaned from OAuth. Primarily used by link_or_register_user'; 516 | 517 | -------------------------------------------------------------------------------- 518 | 519 | create function app_private.link_or_register_user( 520 | f_user_id integer, 521 | f_service character varying, 522 | f_identifier character varying, 523 | f_profile json, 524 | f_auth_details json 525 | ) returns app_public.users as $$ 526 | declare 527 | v_matched_user_id int; 528 | v_matched_authentication_id int; 529 | v_email citext; 530 | v_name text; 531 | v_avatar_url text; 532 | v_user app_public.users; 533 | v_user_email app_public.user_emails; 534 | begin 535 | -- See if a user account already matches these details 536 | select id, user_id 537 | into v_matched_authentication_id, v_matched_user_id 538 | from app_public.user_authentications 539 | where service = f_service 540 | and identifier = f_identifier 541 | limit 1; 542 | 543 | if v_matched_user_id is not null and f_user_id is not null and v_matched_user_id <> f_user_id then 544 | raise exception 'A different user already has this account linked.' using errcode='TAKEN'; 545 | end if; 546 | 547 | v_email = f_profile ->> 'email'; 548 | v_name := f_profile ->> 'name'; 549 | v_avatar_url := f_profile ->> 'avatar_url'; 550 | 551 | if v_matched_authentication_id is null then 552 | if f_user_id is not null then 553 | -- Link new account to logged in user account 554 | insert into app_public.user_authentications (user_id, service, identifier, details) values 555 | (f_user_id, f_service, f_identifier, f_profile) returning id, user_id into v_matched_authentication_id, v_matched_user_id; 556 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values 557 | (v_matched_authentication_id, f_auth_details); 558 | elsif v_email is not null then 559 | -- See if the email is registered 560 | select * into v_user_email from app_public.user_emails where email = v_email and is_verified is true; 561 | if not (v_user_email is null) then 562 | -- User exists! 563 | insert into app_public.user_authentications (user_id, service, identifier, details) values 564 | (v_user_email.user_id, f_service, f_identifier, f_profile) returning id, user_id into v_matched_authentication_id, v_matched_user_id; 565 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values 566 | (v_matched_authentication_id, f_auth_details); 567 | end if; 568 | end if; 569 | end if; 570 | if v_matched_user_id is null and f_user_id is null and v_matched_authentication_id is null then 571 | -- Create and return a new user account 572 | return app_private.register_user(f_service, f_identifier, f_profile, f_auth_details, true); 573 | else 574 | if v_matched_authentication_id is not null then 575 | update app_public.user_authentications 576 | set details = f_profile 577 | where id = v_matched_authentication_id; 578 | update app_private.user_authentication_secrets 579 | set details = f_auth_details 580 | where user_authentication_id = v_matched_authentication_id; 581 | update app_public.users 582 | set 583 | name = coalesce(users.name, v_name), 584 | avatar_url = coalesce(users.avatar_url, v_avatar_url) 585 | where id = v_matched_user_id 586 | returning * into v_user; 587 | return v_user; 588 | else 589 | -- v_matched_authentication_id is null 590 | -- -> v_matched_user_id is null (they're paired) 591 | -- -> f_user_id is not null (because the if clause above) 592 | -- -> v_matched_authentication_id is not null (because of the separate if block above creating a user_authentications) 593 | -- -> contradiction. 594 | raise exception 'This should not occur'; 595 | end if; 596 | end if; 597 | end; 598 | $$ language plpgsql volatile security definer set search_path from current; 599 | 600 | comment on function app_private.link_or_register_user(f_user_id integer, f_service character varying, f_identifier character varying, f_profile json, f_auth_details json) is 601 | E'If you''re logged in, this will link an additional OAuth login to your account if necessary. If you''re logged out it may find if an account already exists (based on OAuth details or email address) and return that, or create a new user account if necessary.'; 602 | 603 | -------------------------------------------------------------------------------- /db/700_forum.sql: -------------------------------------------------------------------------------- 1 | -- Forum example 2 | 3 | create table app_public.forums ( 4 | id serial primary key, 5 | slug text not null check(length(slug) < 30 and slug ~ '^([a-z0-9]-?)+$') unique, 6 | name text not null check(length(name) > 0), 7 | description text not null default '', 8 | created_at timestamptz not null default now(), 9 | updated_at timestamptz not null default now() 10 | ); 11 | alter table app_public.forums enable row level security; 12 | create trigger _100_timestamps 13 | before insert or update on app_public.forums 14 | for each row 15 | execute procedure app_private.tg__update_timestamps(); 16 | 17 | comment on table app_public.forums is 18 | E'A subject-based grouping of topics and posts.'; 19 | comment on column app_public.forums.slug is 20 | E'An URL-safe alias for the `Forum`.'; 21 | comment on column app_public.forums.name is 22 | E'The name of the `Forum` (indicates its subject matter).'; 23 | comment on column app_public.forums.description is 24 | E'A brief description of the `Forum` including it''s purpose.'; 25 | 26 | create policy select_all on app_public.forums for select using (true); 27 | create policy insert_admin on app_public.forums for insert with check (app_public.current_user_is_admin()); 28 | create policy update_admin on app_public.forums for update using (app_public.current_user_is_admin()); 29 | create policy delete_admin on app_public.forums for delete using (app_public.current_user_is_admin()); 30 | grant select on app_public.forums to graphiledemo_visitor; 31 | grant insert(slug, name, description) on app_public.forums to graphiledemo_visitor; 32 | grant update(slug, name, description) on app_public.forums to graphiledemo_visitor; 33 | grant delete on app_public.forums to graphiledemo_visitor; 34 | 35 | -------------------------------------------------------------------------------- 36 | 37 | create table app_public.topics ( 38 | id serial primary key, 39 | forum_id int not null references app_public.forums on delete cascade, 40 | author_id int not null default app_public.current_user_id() references app_public.users on delete cascade, 41 | title text not null check(length(title) > 0), 42 | body text not null default '', 43 | created_at timestamptz not null default now(), 44 | updated_at timestamptz not null default now() 45 | ); 46 | alter table app_public.topics enable row level security; 47 | create trigger _100_timestamps 48 | before insert or update on app_public.topics 49 | for each row 50 | execute procedure app_private.tg__update_timestamps(); 51 | 52 | comment on table app_public.topics is 53 | E'@omit all\nAn individual message thread within a Forum.'; 54 | comment on column app_public.topics.title is 55 | E'The title of the `Topic`.'; 56 | comment on column app_public.topics.body is 57 | E'The body of the `Topic`, which Posts reply to.'; 58 | 59 | create policy select_all on app_public.topics for select using (true); 60 | create policy insert_admin on app_public.topics for insert with check (author_id = app_public.current_user_id()); 61 | create policy update_admin on app_public.topics for update using (author_id = app_public.current_user_id() or app_public.current_user_is_admin()); 62 | create policy delete_admin on app_public.topics for delete using (author_id = app_public.current_user_id() or app_public.current_user_is_admin()); 63 | grant select on app_public.topics to graphiledemo_visitor; 64 | grant insert(forum_id, title, body) on app_public.topics to graphiledemo_visitor; 65 | grant update(title, body) on app_public.topics to graphiledemo_visitor; 66 | grant delete on app_public.topics to graphiledemo_visitor; 67 | 68 | create function app_public.topics_body_summary( 69 | t app_public.topics, 70 | max_length int = 30 71 | ) 72 | returns text 73 | language sql 74 | stable 75 | set search_path from current 76 | as $$ 77 | select case 78 | when length(t.body) > max_length 79 | then left(t.body, max_length - 3) || '...' 80 | else t.body 81 | end; 82 | $$; 83 | 84 | -------------------------------------------------------------------------------- 85 | 86 | create table app_public.posts ( 87 | id serial primary key, 88 | topic_id int not null references app_public.topics on delete cascade, 89 | author_id int not null default app_public.current_user_id() references app_public.users on delete cascade, 90 | body text not null default '', 91 | created_at timestamptz not null default now(), 92 | updated_at timestamptz not null default now() 93 | ); 94 | alter table app_public.posts enable row level security; 95 | create trigger _100_timestamps 96 | before insert or update on app_public.posts 97 | for each row 98 | execute procedure app_private.tg__update_timestamps(); 99 | 100 | comment on table app_public.posts is 101 | E'@omit all\nAn individual message thread within a Forum.'; 102 | comment on column app_public.posts.id is 103 | E'@omit create,update'; 104 | comment on column app_public.posts.topic_id is 105 | E'@omit update'; 106 | comment on column app_public.posts.author_id is 107 | E'@omit create,update'; 108 | comment on column app_public.posts.body is 109 | E'The body of the `Topic`, which Posts reply to.'; 110 | comment on column app_public.posts.created_at is 111 | E'@omit create,update'; 112 | comment on column app_public.posts.updated_at is 113 | E'@omit create,update'; 114 | 115 | create policy select_all on app_public.posts for select using (true); 116 | create policy insert_admin on app_public.posts for insert with check (author_id = app_public.current_user_id()); 117 | create policy update_admin on app_public.posts for update using (author_id = app_public.current_user_id() or app_public.current_user_is_admin()); 118 | create policy delete_admin on app_public.posts for delete using (author_id = app_public.current_user_id() or app_public.current_user_is_admin()); 119 | grant select on app_public.posts to graphiledemo_visitor; 120 | grant insert(topic_id, body) on app_public.posts to graphiledemo_visitor; 121 | grant update(body) on app_public.posts to graphiledemo_visitor; 122 | grant delete on app_public.posts to graphiledemo_visitor; 123 | 124 | 125 | create function app_public.random_number() returns int 126 | language sql stable 127 | as $$ 128 | select 4; 129 | $$; 130 | 131 | comment on function app_public.random_number() 132 | is 'Chosen by fair dice roll. Guaranteed to be random. XKCD#221'; 133 | 134 | create function app_public.forums_about_cats() returns setof app_public.forums 135 | language sql stable 136 | as $$ 137 | select * from app_public.forums where slug like 'cat-%'; 138 | $$; 139 | -------------------------------------------------------------------------------- /db/999_data.sql: -------------------------------------------------------------------------------- 1 | select app_private.link_or_register_user( 2 | null, 3 | 'github', 4 | '6413628', 5 | '{}'::json, 6 | '{}'::json 7 | ); 8 | select app_private.link_or_register_user( 9 | null, 10 | 'github', 11 | '222222', 12 | '{"name":"Chad F"}'::json, 13 | '{}'::json 14 | ); 15 | select app_private.link_or_register_user( 16 | null, 17 | 'github', 18 | '333333', 19 | '{"name":"Bradley A"}'::json, 20 | '{}'::json 21 | ); 22 | select app_private.link_or_register_user( 23 | null, 24 | 'github', 25 | '444444', 26 | '{"name":"Sam L"}'::json, 27 | '{}'::json 28 | ); 29 | select app_private.link_or_register_user( 30 | null, 31 | 'github', 32 | '555555', 33 | '{"name":"Max D"}'::json, 34 | '{}'::json 35 | ); 36 | 37 | insert into app_public.user_emails(user_id, email, is_verified) values 38 | (1, 'benjie@example.com', true); 39 | 40 | insert into app_public.forums(slug, name, description) values 41 | ('testimonials', 'Testimonials', 'How do you rate PostGraphile?'), 42 | ('feedback', 'Feedback', 'How are you finding PostGraphile?'), 43 | ('cat-life', 'Cat Life', 'A forum all about cats and how fluffy they are and how they completely ignore their owners unless there is food. Or yarn.'), 44 | ('cat-help', 'Cat Help', 'A forum to seek advice if your cat is becoming troublesome.'); 45 | 46 | 47 | insert into app_public.topics(forum_id, author_id, title, body) values 48 | (1, 2, 'Thank you!', '500-1500 requests per second on a single server is pretty awesome.'), 49 | (1, 4, 'PostGraphile is powerful', 'PostGraphile is a powerful, idomatic, and elegant tool.'), 50 | (1, 5, 'Recently launched', 'At this point, it’s quite hard for me to come back and enjoy working with REST.'), 51 | (3, 1, 'I love cats!', 'They''re the best!'); 52 | 53 | insert into app_public.posts(topic_id, author_id, body) values 54 | (1, 1, 'I''m super pleased with the performance - thanks!'), 55 | (2, 1, 'Thanks so much!'), 56 | (3, 1, 'Tell me about it - GraphQL is awesome!'), 57 | (4, 1, 'Dont you just love cats? Cats cats cats cats cats cats cats cats cats cats cats cats Cats cats cats cats cats cats cats cats cats cats cats cats'), 58 | (4, 2, 'Yeah cats are really fluffy I enjoy squising their fur they are so goregous and fluffy and squishy and fluffy and gorgeous and squishy and goregous and fluffy and squishy and fluffy and gorgeous and squishy'), 59 | (4, 3, 'I love it when they completely ignore you until they want something. So much better than dogs am I rite?'); 60 | -------------------------------------------------------------------------------- /db/CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | # Conventions used in this database schema: 2 | 3 | ### Naming 4 | 5 | - snake_case for tables, functions, columns (avoids having to put them in quotes in most cases) 6 | - plural table names (avoids conflicts with e.g. `user` built ins, is better depluralized by PostGraphile) 7 | - trigger functions valid for one table only are named tg_[table_name]__[task_name] 8 | - trigger functions valid for many tables are named tg__[task_name] 9 | - trigger names should be prefixed with `_NNN_` where NNN is a three digit number that defines the priority of the trigger (use _500_ if unsure) 10 | - prefer lowercase over UPPERCASE, except for the `NEW`, `OLD` and `TG_OP` keywords. (This is Benjie's personal preference.) 11 | 12 | ### Security 13 | 14 | - all functions should define `set search_path from current` because of `CVE-2018-1058` 15 | - @omit smart comments should not be used for permissions, instead deferring to PostGraphile's RBAC support 16 | - all tables (public or not) should enable RLS 17 | - relevant RLS policy should be defined before granting a permission 18 | - `grant select` should never specify a column list; instead use one-to-one relations as permission boundaries 19 | 20 | ### Explicitness 21 | 22 | - all functions should explicitly state immutable/stable/volatile 23 | - do not override search_path for convenience - prefer to be explicit 24 | 25 | ### Functions 26 | 27 | - if a function can be expressed as a single SQL statement it should use the `sql` language if possible. Other functions should use `plpgsql`. 28 | 29 | ### Relations 30 | 31 | - all foreign key `references` statements should have `on delete` clauses. Some may also want `on update` clauses, but that's optional 32 | - all comments should be defined using '"escape" string constants' - e.g. `E'...'` - because this more easily allows adding smart comments 33 | - defining things (primary key, checks, unique constraints, etc) within the `create table` statement is preferable to adding them after 34 | 35 | ### General conventions (e.g. for PostGraphile compatibility) 36 | 37 | - avoid plv8 and other extensions that aren't built in because they can be complex for people to install (and this is a demo project) 38 | - functions should not use IN/OUT/INOUT parameters 39 | - @omit smart comments should be used heavily to remove fields we don't currently need in GraphQL - we can always remove them later 40 | 41 | ### Definitions 42 | 43 | Please adhere to the following templates (respecting newlines): 44 | 45 | 46 | Tables: 47 | 48 | ```sql 49 | create table . ( 50 | ... 51 | ); 52 | ``` 53 | 54 | SQL functions: 55 | 56 | ```sql 57 | create function () returns as $$ 58 | select ... 59 | from ... 60 | inner join ... 61 | on ... 62 | where ... 63 | and ... 64 | order by ... 65 | limit ...; 66 | $$ language sql set search_path from current; 67 | ``` 68 | 69 | PL/pgSQL functions: 70 | 71 | ```sql 72 | create function () returns as $$ 73 | declare 74 | v_[varname] [ = ]; 75 | ... 76 | begin 77 | if ... then 78 | ... 79 | end if; 80 | return ; 81 | end; 82 | $$ language plpgsql set search_path from current; 83 | ``` 84 | 85 | Triggers: 86 | 87 | ```sql 88 | create trigger _NNN_trigger_name 89 | on . 90 | for each row [when ()] 91 | execute procedure (...); 92 | ``` 93 | 94 | Comments: 95 | 96 | ```sql 97 | comment on is 98 | E'...'; 99 | ``` 100 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # Database 2 | 3 | Since this is a realistic example, it has realistic concerns in it, such as 4 | mitigating brute force login attacks. This means that the example might be a 5 | lot bigger than you'd first expect; but it's designed to be a solid start you 6 | can use in your own applications (after a find and replace for 'graphiledemo'!) 7 | so when you start, jump straight to the files >= 500 as they contain the forum 8 | application logic. 9 | 10 | Note also that this application works with both social (OAuth) login (when used 11 | with a server that supports this), and with traditional username/password 12 | login, and the social login stores your access tokens so that the server-side 13 | may use them (e.g. to look up issues in GitHub when they're mentioned in one of 14 | your posts). This means the user tables might be significantly more complex 15 | than your application requires; feel free to simplify them when you build your 16 | own schema. 17 | 18 | ### Conventions 19 | 20 | With the exception of `100_jobs.sql` which was imported from a previous project 21 | and requires bringing in line, the SQL files in this repository try to adhere 22 | to the conventions defined in [CONVENTIONS.md](./CONVENTIONS.md). PRs to fix 23 | our adherence to these conventions would be welcome. Someone writing an SQL 24 | equivalent of ESLint and/or prettier would be even more welcome! 25 | 26 | ### Common logic 27 | 28 | Definitions < 500 are common to all sorts of applications, they solve common 29 | concerns such as storing user data, logging people in, triggering password 30 | reset emails, avoiding brute force attacks and more. 31 | 32 | `100_jobs.sql`: handles the job queue (tasks to run in the background, such 33 | as sending emails, polling APIs, etc). 34 | 35 | `200_schemas.sql`: defines our common schemas `app_public`, and `app_private` 36 | and adds base permissions to them. 37 | 38 | `300_utils.sql`: Useful utility functions. 39 | 40 | `400_users.sql`: Users, authentication, emails, brute force mitigation, etc. 41 | 42 | 43 | ### Application specific logic 44 | 45 | Definitions >= 500 are application specific, defining the tables in your 46 | application, and dealing with concerns such as a welcome email or customising 47 | the user tables to your whim. We use them here to add our forum-specific logic. 48 | 49 | `700_forum.sql` 50 | 51 | ### Migrations 52 | 53 | This project doesn't currently deal with migrations. Every time you pull down a 54 | new version you should reset your database; we do not (currently) care about 55 | supporting legacy versions of this example repo. There are many projects that 56 | help you deal with migrations, two of note are [sqitch](https://sqitch.org/) 57 | and 58 | [db-migrate](https://db-migrate.readthedocs.io/en/latest/Getting%20Started/usage/). 59 | -------------------------------------------------------------------------------- /db/reset.sql: -------------------------------------------------------------------------------- 1 | -- First, we clean out the old stuff 2 | 3 | drop schema if exists app_public cascade; 4 | drop schema if exists app_hidden cascade; 5 | drop schema if exists app_private cascade; 6 | drop schema if exists app_jobs cascade; 7 | 8 | -------------------------------------------------------------------------------- 9 | 10 | -- Definitions <500 are common to all sorts of applications, 11 | -- they solve common concerns such as storing user data, 12 | -- logging people in, triggering password reset emails, 13 | -- mitigating brute force attacks and more. 14 | 15 | -- Background worker tasks 16 | \ir 100_jobs.sql 17 | 18 | -- app_public, app_private and base permissions 19 | \ir 200_schemas.sql 20 | 21 | -- Useful utility functions 22 | \ir 300_utils.sql 23 | 24 | -- Users, authentication, emails, etc 25 | \ir 400_users.sql 26 | 27 | -------------------------------------------------------------------------------- 28 | 29 | -- Definitions >=500 are application specific, defining the tables 30 | -- in your application, and dealing with concerns such as a welcome 31 | -- email or customising the user tables to your whim 32 | 33 | -- Forum tables 34 | \ir 700_forum.sql 35 | 36 | \ir 999_data.sql -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "db:reset": "psql -X1v ON_ERROR_STOP=1 graphiledemo -f ./db/reset.sql", 4 | "lint": "eslint .", 5 | "client:react": "cd client-react; npm start", 6 | "server:koa2": "cd server-koa2; npm start", 7 | "server:postgraphile": ". ./.env; postgraphile", 8 | "start": "concurrently --kill-others 'npm run server:koa2' 'npm run client:react'" 9 | }, 10 | "private": true, 11 | "workspaces": { 12 | "packages": [ 13 | "*" 14 | ], 15 | "nohoist": [ 16 | "**/react-scripts", 17 | "**/react-scripts/**" 18 | ] 19 | }, 20 | "devDependencies": { 21 | "babel-eslint": "9.0.0", 22 | "concurrently": "3.6.0", 23 | "eslint": "5.6.0", 24 | "eslint-config-prettier": "2.9.0", 25 | "eslint-plugin-graphql": "2.1.1", 26 | "eslint-plugin-prettier": "2.6.1", 27 | "eslint-plugin-react": "7.10.0", 28 | "eslint_d": "5.3.1", 29 | "prettier": "1.13.6" 30 | }, 31 | "engines": { 32 | "node": ">=8.11.3 <11" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | a { 8 | text-decoration: none; 9 | } 10 | 11 | .Header { 12 | background-color: #222; 13 | color: white; 14 | padding: 1rem; 15 | display: flex; 16 | flex-wrap: wrap; 17 | align-items: center; 18 | justify-content: space-between; 19 | } 20 | .Header a { 21 | color: white; 22 | } 23 | 24 | .Header-titleContainer { 25 | display: flex; 26 | align-items: center; 27 | } 28 | 29 | .Header-title { 30 | font-size: 2rem; 31 | } 32 | 33 | .Header-logo { 34 | height: 3.5rem; 35 | margin: -0.5rem 0; 36 | padding-right: 1rem; 37 | } 38 | 39 | .Main { 40 | padding: 1rem; 41 | } 42 | 43 | .Main > h1:first-child, 44 | .Main > h2:first-child, 45 | .Main > h3:first-child, 46 | .Main > h4:first-child, 47 | .Main > h5:first-child, 48 | .Main > h6:first-child { 49 | margin-top: 0; 50 | padding-top: 0; 51 | } 52 | 53 | .ForumItem-name { 54 | padding: 0.5rem; 55 | } 56 | 57 | .ForumItem-description { 58 | padding: 0.5rem; 59 | } 60 | 61 | .ForumItem-tools { 62 | background-color: #555; 63 | color: white; 64 | padding: 0.5rem; 65 | } 66 | 67 | .Forum-header, .ForumItem { 68 | margin-bottom: 15px; 69 | background-image: linear-gradient(-90deg, #32a3ff, #013f7b); 70 | color: white; 71 | padding: 12px; 72 | } 73 | 74 | .Forum-header { 75 | font-size: 2rem; 76 | line-height: 1.4em; 77 | } 78 | 79 | .Forum-header a, .ForumItem a { 80 | color: white; 81 | } 82 | 83 | .ForumItem-description { 84 | font-size: 1.2em; 85 | } 86 | 87 | .Forum-description { 88 | padding: 12px; 89 | margin-left: auto; 90 | margin-right: auto; 91 | margin-bottom: 15px; 92 | color: #919191; 93 | } 94 | 95 | .Topics-container, .Posts-container { 96 | border-collapse: collapse; 97 | max-width: 1110px; 98 | margin-left: auto; 99 | margin-right: auto; 100 | } 101 | 102 | .TopicItem { 103 | border-bottom: 1px solid #e9e9e9; 104 | display: table-row; 105 | } 106 | 107 | .TopicItem a, .WelcomeMessage a { 108 | color: #0074ca; 109 | } 110 | 111 | .TopicItem-title { 112 | padding-top: 12px; 113 | padding-bottom: 12px; 114 | padding-left: none; 115 | width: 500px; 116 | } 117 | 118 | .Topics-TopicItemHeader th { 119 | color: #919191; 120 | font-weight: normal; 121 | padding-top: 12px; 122 | padding-bottom: 12px; 123 | padding-left: none; 124 | text-align: left; 125 | } 126 | 127 | .Topics-container tbody { 128 | border-top: 3px solid #e9e9e9; 129 | } 130 | 131 | .TopicItem-user, .TopicItem-replies, TopicItem-date { 132 | width: 65px; 133 | padding-left: none; 134 | text-align: left; 135 | } 136 | 137 | .Topic-header { 138 | color: #222222; 139 | font-size: 1.7em; 140 | margin-bottom: 15px; 141 | font-size: 2rem; 142 | line-height: 1.4em; 143 | padding: 12px; 144 | } 145 | 146 | .Posts-container { 147 | 148 | } 149 | 150 | .PostItem { 151 | border-top: 1px solid #e9e9e9; 152 | display: flex; 153 | flex-direction: row; 154 | max-width: 1000px; 155 | padding: 1em 0; 156 | } 157 | 158 | .PostItem-meta { 159 | min-width: 200px; 160 | } 161 | 162 | .PostItem-user { 163 | font-weight: bold; 164 | } 165 | 166 | .PostItem-user--with-avatar { 167 | display: flex; 168 | flex-direction: column; 169 | } 170 | 171 | .PostItem-avatar { 172 | max-width: 50px; 173 | max-height: 50px; 174 | margin-bottom: .5em; 175 | } 176 | 177 | .PostItem-date { 178 | color: #919191; 179 | font-size: 0.8em; 180 | } 181 | 182 | .PostItem-body { 183 | 184 | } 185 | 186 | .PostItem-topic { 187 | border: none; 188 | } 189 | -------------------------------------------------------------------------------- /scripts/schema_dump: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | 5 | # There's no easy way to exclude postgraphile_watch from the dump, so we drop and and restore it at the end 6 | echo "DROP SCHEMA IF EXISTS postgraphile_watch CASCADE;" | psql -X1 -v ON_ERROR_STOP=1 graphiledemo 7 | 8 | # Here we do a schema only dump of the graphiledemo DB to the data folder 9 | pg_dump -s -O -f ${SCRIPTS_DIR}/../data/schema.sql graphiledemo 10 | 11 | # Restore the watch schema 12 | cat ${SCRIPTS_DIR}/../node_modules/graphile-build-pg/res/watch-fixtures.sql | psql -X1 -v ON_ERROR_STOP=1 graphiledemo 13 | -------------------------------------------------------------------------------- /server-koa2/middleware/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const middlewares = fs 4 | .readdirSync(__dirname) 5 | .filter(fn => fn !== "index.js") 6 | .filter(fn => fn.match(/^[^.].*\.js$/)) 7 | .map(str => str.slice(0, -3)); 8 | 9 | middlewares.forEach(name => { 10 | // eslint-disable-next-line import/no-dynamic-require 11 | exports[name] = require(`./${name}`); 12 | }); 13 | -------------------------------------------------------------------------------- /server-koa2/middleware/installFrontendServer.js: -------------------------------------------------------------------------------- 1 | const httpProxy = require("http-proxy"); 2 | 3 | module.exports = function installFrontendServer(app, server) { 4 | const proxy = httpProxy.createProxyServer({ 5 | target: `http://localhost:${process.env.CLIENT_PORT}`, 6 | ws: true, 7 | }); 8 | app.use(ctx => { 9 | // Bypass koa for HTTP proxying 10 | ctx.respond = false; 11 | proxy.web(ctx.req, ctx.res, {}, _e => { 12 | ctx.res.statusCode = 503; 13 | ctx.res.end( 14 | "Error occurred while proxying to client application - is it running?" 15 | ); 16 | }); 17 | }); 18 | server.on("upgrade", (req, socket, head) => { 19 | proxy.ws(req, socket, head); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /server-koa2/middleware/installPassport.js: -------------------------------------------------------------------------------- 1 | const passport = require("koa-passport"); 2 | const route = require("koa-route"); 3 | const { Strategy: GitHubStrategy } = require("passport-github"); 4 | 5 | /* 6 | * This file uses regular Passport.js authentication, both for 7 | * username/password and for login with GitHub. You can easily add more OAuth 8 | * providers to this file. For more information, see: 9 | * 10 | * http://www.passportjs.org/ 11 | */ 12 | 13 | module.exports = function installPassport(app, { rootPgPool }) { 14 | passport.serializeUser((user, done) => { 15 | done(null, user.id); 16 | }); 17 | 18 | passport.deserializeUser(async (id, callback) => { 19 | let error = null; 20 | let user; 21 | try { 22 | const { 23 | rows: [_user], 24 | } = await rootPgPool.query( 25 | `select users.* from app_public.users where users.id = $1`, 26 | [id] 27 | ); 28 | user = _user || false; 29 | } catch (e) { 30 | error = e; 31 | } finally { 32 | callback(error, user); 33 | } 34 | }); 35 | app.use(passport.initialize()); 36 | app.use(passport.session()); 37 | 38 | if (process.env.GITHUB_KEY && process.env.GITHUB_SECRET) { 39 | passport.use( 40 | new GitHubStrategy( 41 | { 42 | clientID: process.env.GITHUB_KEY, 43 | clientSecret: process.env.GITHUB_SECRET, 44 | callbackURL: `${process.env.ROOT_URL}/auth/github/callback`, 45 | passReqToCallback: true, 46 | }, 47 | async function(req, accessToken, refreshToken, profile, done) { 48 | let error; 49 | let user; 50 | try { 51 | const { rows } = await rootPgPool.query( 52 | `select * from app_private.link_or_register_user($1, $2, $3, $4, $5) users where not (users is null);`, 53 | [ 54 | (req.user && req.user.id) || null, 55 | "github", 56 | profile.id, 57 | JSON.stringify({ 58 | username: profile.username, 59 | avatar_url: profile._json.avatar_url, 60 | name: profile.displayName, 61 | }), 62 | JSON.stringify({ 63 | accessToken, 64 | refreshToken, 65 | }), 66 | ] 67 | ); 68 | user = rows[0] || false; 69 | } catch (e) { 70 | error = e; 71 | } finally { 72 | done(error, user); 73 | } 74 | } 75 | ) 76 | ); 77 | 78 | app.use(route.get("/auth/github", passport.authenticate("github"))); 79 | 80 | app.use( 81 | route.get( 82 | "/auth/github/callback", 83 | passport.authenticate("github", { 84 | successRedirect: "/", 85 | failureRedirect: "/login", 86 | }) 87 | ) 88 | ); 89 | } else { 90 | console.error( 91 | "WARNING: you've not set up the GitHub application for login; see `.env` for details" 92 | ); 93 | } 94 | app.use( 95 | route.get("/logout", async ctx => { 96 | ctx.logout(); 97 | ctx.redirect("/"); 98 | }) 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /server-koa2/middleware/installPostGraphile.js: -------------------------------------------------------------------------------- 1 | const { postgraphile } = require("postgraphile"); 2 | const PassportLoginPlugin = require("../../shared/plugins/PassportLoginPlugin"); 3 | const { 4 | library: { connection, schema, options }, 5 | } = require("../../.postgraphilerc.js"); 6 | 7 | module.exports = function installPostGraphile(app, { rootPgPool }) { 8 | app.use((ctx, next) => { 9 | // PostGraphile deals with (req, res) but we want access to sessions from `pgSettings`, so we make the ctx available on req. 10 | ctx.req.ctx = ctx; 11 | return next(); 12 | }); 13 | 14 | app.use( 15 | postgraphile(connection, schema, { 16 | // Import our shared options 17 | ...options, 18 | 19 | // Since we're using sessions we'll also want our login plugin 20 | appendPlugins: [ 21 | // All the plugins in our shared config 22 | ...(options.appendPlugins || []), 23 | 24 | // Adds the `login` mutation to enable users to log in 25 | PassportLoginPlugin, 26 | ], 27 | 28 | // Given a request object, returns the settings to set within the 29 | // Postgres transaction used by GraphQL. 30 | pgSettings(req) { 31 | return { 32 | role: "graphiledemo_visitor", 33 | "jwt.claims.user_id": req.ctx.state.user && req.ctx.state.user.id, 34 | }; 35 | }, 36 | 37 | // The return value of this is added to `context` - the third argument of 38 | // GraphQL resolvers. This is useful for our custom plugins. 39 | additionalGraphQLContextFromRequest(req) { 40 | return { 41 | // Let plugins call priviliged methods (e.g. login) if they need to 42 | rootPgPool, 43 | 44 | // Use this to tell Passport.js we're logged in 45 | login: user => 46 | new Promise((resolve, reject) => { 47 | req.ctx.login(user, err => (err ? reject(err) : resolve())); 48 | }), 49 | }; 50 | }, 51 | }) 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /server-koa2/middleware/installSession.js: -------------------------------------------------------------------------------- 1 | const session = require("koa-session"); 2 | 3 | module.exports = function installSession(app) { 4 | app.keys = [process.env.SECRET]; 5 | app.use(session({}, app)); 6 | }; 7 | -------------------------------------------------------------------------------- /server-koa2/middleware/installSharedStatic.js: -------------------------------------------------------------------------------- 1 | const koaStatic = require("koa-static"); 2 | 3 | module.exports = function installSharedStatic(app) { 4 | app.use(koaStatic(`${__dirname}/../../public`)); 5 | }; 6 | -------------------------------------------------------------------------------- /server-koa2/middleware/installStandardKoaMiddlewares.js: -------------------------------------------------------------------------------- 1 | const helmet = require("koa-helmet"); 2 | const cors = require("@koa/cors"); 3 | // const jwt = require("koa-jwt"); 4 | const compress = require("koa-compress"); 5 | const bunyanLogger = require("koa-bunyan-logger"); 6 | const bodyParser = require("koa-bodyparser"); 7 | 8 | module.exports = function installStandardKoaMiddlewares(app) { 9 | // These middlewares aren't required, I'm using them to check PostGraphile 10 | // works with Koa. 11 | app.use(helmet()); 12 | app.use(cors()); 13 | //app.use(jwt({secret: process.env.SECRET})) 14 | app.use(compress()); 15 | app.use(bunyanLogger()); 16 | app.use(bodyParser()); 17 | }; 18 | -------------------------------------------------------------------------------- /server-koa2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server-koa", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "start": "if [ -x ../.env ]; then . ../.env; fi; NODE_ENV=development nodemon server.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Benjie Gillam ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@graphile-contrib/pg-simplify-inflector": "^4.0.0-alpha.0", 15 | "@koa/cors": "2.2.1", 16 | "graphile-utils": "^4.4.0-beta.1", 17 | "http-proxy": "1.18.1", 18 | "koa": "2.5.1", 19 | "koa-bodyparser": "4.2.1", 20 | "koa-bunyan-logger": "2.1.0", 21 | "koa-compress": "3.0.0", 22 | "koa-helmet": "4.0.0", 23 | "koa-jwt": "4.0.4", 24 | "koa-passport": "5.0.0", 25 | "koa-route": "3.2.0", 26 | "koa-session": "5.8.1", 27 | "koa-static": "5.0.0", 28 | "passport-github": "1.1.0", 29 | "pg": "7.4.3", 30 | "postgraphile": "^4.4.1-rc.0" 31 | }, 32 | "devDependencies": { 33 | "nodemon": "1.17.5" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server-koa2/server.js: -------------------------------------------------------------------------------- 1 | const http = require("http"); 2 | const Koa = require("koa"); 3 | const pg = require("pg"); 4 | const sharedUtils = require("../shared/utils"); 5 | const middleware = require("./middleware"); 6 | 7 | sharedUtils.sanitiseEnv(); 8 | 9 | const rootPgPool = new pg.Pool({ 10 | connectionString: process.env.ROOT_DATABASE_URL 11 | }); 12 | 13 | const isDev = process.env.NODE_ENV === "development"; 14 | 15 | const app = new Koa(); 16 | const server = http.createServer(app.callback()); 17 | 18 | middleware.installStandardKoaMiddlewares(app); 19 | middleware.installSession(app); 20 | middleware.installPassport(app, { rootPgPool }); 21 | middleware.installPostGraphile(app, { rootPgPool }); 22 | middleware.installSharedStatic(app); 23 | middleware.installFrontendServer(app, server); 24 | 25 | const PORT = parseInt(process.env.PORT, 10) || 3000; 26 | server.listen(PORT); 27 | console.log(`Listening on port ${PORT}`); 28 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | export NODE_ENV=development 4 | 5 | if [ -x .env ]; then 6 | . ./.env 7 | if [ "$SUPERUSER_PASSWORD" = "" ]; then 8 | echo ".env already exists, but it doesn't define SUPERUSER_PASSWORD - aborting!" 9 | exit 1; 10 | fi 11 | if [ "$AUTH_USER_PASSWORD" = "" ]; then 12 | echo ".env already exists, but it doesn't define AUTH_USER_PASSWORD - aborting!" 13 | exit 1; 14 | fi 15 | echo "Configuration already exists, using existing secrets." 16 | else 17 | # This will generate passwords that are safe to use in envvars without needing to be escaped: 18 | SUPERUSER_PASSWORD="$(openssl rand -base64 30 | tr '+/' '-_')" 19 | AUTH_USER_PASSWORD="$(openssl rand -base64 30 | tr '+/' '-_')" 20 | 21 | # This is our '.env' config file, we're writing it now so that if something goes wrong we won't lose the passwords. 22 | cat >> .env < ({ 4 | typeDefs: gql` 5 | input RegisterInput { 6 | username: String! 7 | email: String! 8 | password: String! 9 | name: String 10 | avatarUrl: String 11 | } 12 | 13 | type RegisterPayload { 14 | user: User! @pgField 15 | } 16 | 17 | input LoginInput { 18 | username: String! 19 | password: String! 20 | } 21 | 22 | type LoginPayload { 23 | user: User! @pgField 24 | } 25 | 26 | extend type Mutation { 27 | register(input: RegisterInput!): RegisterPayload 28 | login(input: LoginInput!): LoginPayload 29 | } 30 | `, 31 | resolvers: { 32 | Mutation: { 33 | async register( 34 | mutation, 35 | args, 36 | context, 37 | resolveInfo, 38 | { selectGraphQLResultFromTable } 39 | ) { 40 | const { 41 | username, 42 | password, 43 | email, 44 | name = null, 45 | avatarUrl = null, 46 | } = args.input; 47 | const { rootPgPool, login, pgClient } = context; 48 | try { 49 | // Call our register function from the database 50 | const { 51 | rows: [user], 52 | } = await rootPgPool.query( 53 | `select users.* from app_private.really_create_user( 54 | username => $1, 55 | email => $2, 56 | email_is_verified => false, 57 | name => $3, 58 | avatar_url => $4, 59 | password => $5 60 | ) users where not (users is null)`, 61 | [username, email, name, avatarUrl, password] 62 | ); 63 | 64 | if (!user) { 65 | throw new Error("Registration failed"); 66 | } 67 | 68 | // Tell Passport.js we're logged in 69 | await login(user); 70 | // Tell pg we're logged in 71 | await pgClient.query("select set_config($1, $2, true);", [ 72 | "jwt.claims.user_id", 73 | user.id, 74 | ]); 75 | 76 | // Fetch the data that was requested from GraphQL, and return it 77 | const sql = build.pgSql; 78 | const [row] = await selectGraphQLResultFromTable( 79 | sql.fragment`app_public.users`, 80 | (tableAlias, sqlBuilder) => { 81 | sqlBuilder.where( 82 | sql.fragment`${tableAlias}.id = ${sql.value(user.id)}` 83 | ); 84 | } 85 | ); 86 | return { 87 | data: row, 88 | }; 89 | } catch (e) { 90 | console.error(e); 91 | // TODO: determine why it failed 92 | throw new Error("Registration failed"); 93 | } 94 | }, 95 | async login( 96 | mutation, 97 | args, 98 | context, 99 | resolveInfo, 100 | { selectGraphQLResultFromTable } 101 | ) { 102 | const { username, password } = args.input; 103 | const { rootPgPool, login, pgClient } = context; 104 | try { 105 | // Call our login function to find out if the username/password combination exists 106 | const { 107 | rows: [user], 108 | } = await rootPgPool.query( 109 | `select users.* from app_private.login($1, $2) users where not (users is null)`, 110 | [username, password] 111 | ); 112 | 113 | if (!user) { 114 | throw new Error("Login failed"); 115 | } 116 | 117 | // Tell Passport.js we're logged in 118 | await login(user); 119 | // Tell pg we're logged in 120 | await pgClient.query("select set_config($1, $2, true);", [ 121 | "jwt.claims.user_id", 122 | user.id, 123 | ]); 124 | 125 | // Fetch the data that was requested from GraphQL, and return it 126 | const sql = build.pgSql; 127 | const [row] = await selectGraphQLResultFromTable( 128 | sql.fragment`app_public.users`, 129 | (tableAlias, sqlBuilder) => { 130 | sqlBuilder.where( 131 | sql.fragment`${tableAlias}.id = ${sql.value(user.id)}` 132 | ); 133 | } 134 | ); 135 | return { 136 | data: row, 137 | }; 138 | } catch (e) { 139 | console.error(e); 140 | // TODO: check that this is indeed why it failed 141 | throw new Error("Login failed: incorrect username/password"); 142 | } 143 | }, 144 | }, 145 | }, 146 | })); 147 | module.exports = PassportLoginPlugin; 148 | -------------------------------------------------------------------------------- /shared/utils.js: -------------------------------------------------------------------------------- 1 | exports.sanitiseEnv = () => { 2 | const requiredEnvvars = ["AUTH_DATABASE_URL", "ROOT_DATABASE_URL"]; 3 | requiredEnvvars.forEach(envvar => { 4 | if (!process.env[envvar]) { 5 | throw new Error( 6 | `Could not find process.env.${envvar} - did you remember to run the setup script? Have you sourced the environmental variables file '.env'?` 7 | ); 8 | } 9 | }); 10 | 11 | process.env.NODE_ENV = process.env.NODE_ENV || "development"; 12 | }; 13 | --------------------------------------------------------------------------------