├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── docs ├── ERD-codersquare.md ├── PRD-codersquare.md ├── ui designs │ ├── 1 home page.png │ ├── 2 home page (hovers).png │ ├── 3 home page (signed in).png │ ├── 4 new post page.png │ ├── 5 sign in page.png │ ├── 6 sign up page.png │ ├── 7 comments page.png │ ├── 8 comments page (add comment).png │ ├── 9 user profile page.png │ ├── favicon.ico │ └── logo.svg └── wireframes.jpeg ├── package-lock.json ├── package.json ├── server ├── jest.config.js ├── package-lock.json ├── package.json ├── src │ ├── .env.local │ ├── auth.ts │ ├── datastore │ │ ├── dao │ │ │ ├── CommentDao.ts │ │ │ ├── LikeDao.ts │ │ │ ├── PostDao.ts │ │ │ └── UserDao.ts │ │ ├── index.ts │ │ └── sql │ │ │ ├── index.ts │ │ │ ├── migrations │ │ │ ├── 001-initial.sql │ │ │ └── 002-add-comment-likes.sql │ │ │ └── seeds.ts │ ├── env.ts │ ├── handlers │ │ ├── commentHandler.ts │ │ ├── likeHandler.ts │ │ ├── postHandler.ts │ │ └── userHandler.ts │ ├── index.ts │ ├── logging.ts │ ├── middleware │ │ ├── authMiddleware.ts │ │ ├── errorMiddleware.ts │ │ └── loggerMiddleware.ts │ ├── server.ts │ ├── test │ │ ├── integration.test.ts │ │ └── testserver.ts │ └── types.ts └── tsconfig.json ├── shared ├── index.ts ├── package-lock.json ├── package.json ├── src │ ├── api.ts │ ├── endpoints.ts │ ├── errors.ts │ └── types.ts └── tsconfig.json └── web ├── .gitignore ├── craco.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo.svg ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── assets │ └── logo.svg ├── components │ ├── comment-card.tsx │ ├── navbar.tsx │ ├── post-card.tsx │ └── required-input.tsx ├── contexts │ └── userContext.tsx ├── doc-title.tsx ├── fetch │ ├── auth.ts │ └── index.ts ├── index.css ├── index.tsx ├── pages │ ├── list-posts.tsx │ ├── new-post.tsx │ ├── sign-in.tsx │ ├── sign-up.tsx │ ├── user-profile.tsx │ └── view-post.tsx ├── react-app-env.d.ts ├── routes.ts ├── theme.tsx └── util.ts └── tsconfig.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: self-serve deployer 2 | 3 | on: 4 | push: 5 | branches: [deploy-prod] 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: self-hosted 12 | steps: 13 | - name: checkout repo+branch 14 | uses: actions/checkout@v3 15 | - name: setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | - name: provision database 20 | run: | 21 | mkdir -p "$(dirname ${{ secrets.DB_PATH }})" 22 | touch ${{ secrets.DB_PATH }} 23 | - name: install and build server 24 | run: | 25 | cd server 26 | npm install 27 | - name: restart server 28 | env: 29 | DB_PATH: ${{ secrets.DB_PATH }} 30 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 31 | PASSWORD_SALT: ${{ secrets.PASSWORD_SALT }} 32 | ENV: ${{ secrets.ENV }} 33 | PORT: ${{ secrets.PORT }} 34 | run: | 35 | cd server 36 | npm run start:prod 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .DS_Store 107 | *.sqlite 108 | 109 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | web/build 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "arrowParens": "avoid", 8 | "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 9 | "importOrderSeparation": true, 10 | "importOrderSortSpecifiers": true, 11 | "printWidth": 100 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yasser Elsayed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codersquare 2 | 3 | A social web app for sharing learning resources in a hackernews-style 4 | experience. I'm building this publicly on my [YouTube 5 | channel](https://www.youtube.com/playlist?list=PL9ExMy1CBZjmRw0JcocbXKdy271BvWBGq), 6 | contributions are welcome, feel free to send a PR or open an issue. 7 | 8 | The stack is Javascript all the way, try cloning this repo, running `npm install` and `npm start` inside the server directory. See the wireframes, PRD 9 | and ERD in the docs directory for the desired experience. 10 | 11 | This is an MIT repo that we're using for learning, feel free to fork away and 12 | use as you wish. 13 | 14 | ![Screenshot](/../main/docs/ui%20designs/1%20home%20page.png) 15 | -------------------------------------------------------------------------------- /docs/ERD-codersquare.md: -------------------------------------------------------------------------------- 1 | # ERD: codersquare 2 | 3 | This document explores the design of codersquare, a social experience for 4 | sharing useful programming resources. 5 | 6 | We'll use a basic client/server architecture, where a single server is deployed 7 | on a cloud provider next to a relational database, and serving HTTP traffic from 8 | a public endpoint. 9 | 10 | ## Storage 11 | 12 | We'll use a relational database (schema follows) to fast retrieval of posts and 13 | comments. A minimal database implementation such as sqlite3 suffices, although 14 | we can potentially switch to something with a little more power such as 15 | PostgreSql if necessary. Data will be stored on the server on a separate, backed 16 | up volume for resilience. There will be no replication or sharding of data at 17 | this early stage. 18 | 19 | We ruled out storage-as-a-service services such as Firestore and the like in 20 | order to showcase building a standalone backend for educational purposes. 21 | 22 | ### Schema: 23 | 24 | We'll need at least the following entities to implement the service: 25 | 26 | **Users**: 27 | | Column | Type | 28 | |--------|------| 29 | | ID | STRING/UUID | 30 | | First/Last name | STRING | 31 | | Password | STRING | 32 | | Email | STRING | 33 | | Username | STRING | 34 | 35 | **Posts**: 36 | | Column | Type | 37 | |--------|------| 38 | | ID | STRING/UUID | 39 | | Title | STRING | 40 | | URL | STRING | 41 | | UserId | STRING/UUID | 42 | | PostedAt | Timestamp | 43 | 44 | **Likes**: 45 | | Column | Type | 46 | |--------|------| 47 | | UserId | STRING/UUID | 48 | | PostId | STRING | 49 | 50 | **Comments**: 51 | | Column | Type | 52 | |---------|------| 53 | | ID | STRING | 54 | | UserId | STRING/UUID | 55 | | PostId | STRING | 56 | | Comment | STRING | 57 | | PostedAt | Timestamp | 58 | 59 | ## Server 60 | 61 | A simple HTTP server is responsible for authentication, serving stored data, and 62 | potentially ingesting and serving analytics data. 63 | 64 | - Node.js is selected for implementing the server for speed of development. 65 | - Express.js is the web server framework. 66 | - Sequelize to be used as an ORM. 67 | 68 | ### Auth 69 | 70 | For v1, a simple JWT-based auth mechanism is to be used, with passwords 71 | encrypted and stored in the database. OAuth is to be added initially or later 72 | for Google + Facebook and maybe others (Github?). 73 | 74 | ### API 75 | 76 | **Auth**: 77 | 78 | ``` 79 | /signIn [POST] 80 | /signUp [POST] 81 | /signOut [POST] 82 | ``` 83 | 84 | **Posts**: 85 | 86 | ``` 87 | /posts/list [GET] 88 | /posts/new [POST] 89 | /posts/:id [GET] 90 | /posts/:id [DELETE] 91 | ``` 92 | 93 | **Likes**: 94 | 95 | ``` 96 | /likes/new [POST] 97 | ``` 98 | 99 | **Comments**: 100 | 101 | ``` 102 | /comments/new [POST] 103 | /comments/list [GET] 104 | /comments/:id [DELETE] 105 | ``` 106 | 107 | ## Clients 108 | 109 | For now we'll start with a single web client, possibly adding mobile clients later. 110 | 111 | The web client will be implemented in React.js. 112 | See Figma/screenshots for details. 113 | API server will serve a static bundle of the React app. 114 | Uses ReactQuery to talk to the backend. 115 | Uses Chakra UI for building the CSS components. 116 | 117 | ## Hosting 118 | 119 | The code will be hosted on Github, PRs and issues welcome. 120 | 121 | The web client will be hosted using any free web hosting platform such as firebase 122 | or netlify. A domain will be purchased for the site, and configured to point to the 123 | web host's server public IP. 124 | 125 | We'll deploy the server to a (likely shared) VPS for flexibility. The VM will have 126 | HTTP/HTTPS ports open, and we'll start with a manual deployment, to be automated 127 | later using Github actions or similar. The server will have closed CORS policy except 128 | for the domain name and the web host server. 129 | -------------------------------------------------------------------------------- /docs/PRD-codersquare.md: -------------------------------------------------------------------------------- 1 | # PRD: Codersquare 2 | 3 | Codersquare is a social web app for sharing learning resources in a 4 | hackernews-style experience. It allows users to post links to articles, videos, 5 | channels, or other public resources on the web, and other users to vote and 6 | comment on those resources. 7 | 8 | # Experience 9 | 10 | ## Post list 11 | 12 | The site's landing page is a list of links, each link showing up with a title 13 | that was given to it by the poster. Links are also given scores based on their 14 | popularity and age, and are sorted as such. 15 | 16 | Without signing in, users are able to browse this list and navigate to the 17 | posted links. After signing in, users can also add comments to a post, upvote a 18 | post, or add and delete their own posts. 19 | 20 | ## Tagging 21 | 22 | A post can have a set of tags to describe its different attributes, usually 23 | related to its topic. A special tag is added for the post's language (e.g. 24 | Arabic), and users can filter the experience using those tags. 25 | 26 | ## Comments 27 | 28 | Comments are sorted reverse-chronologically for any given post. Users are able 29 | to delete their own comments. 30 | 31 | # Market 32 | 33 | This is a hybrid experience between news sites such as Hackernews or Reddit, and 34 | learning sites that collect resources into a course-like experience such as 35 | Coursera, Udemy, or FreeCodeCamp. 36 | -------------------------------------------------------------------------------- /docs/ui designs/1 home page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/1 home page.png -------------------------------------------------------------------------------- /docs/ui designs/2 home page (hovers).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/2 home page (hovers).png -------------------------------------------------------------------------------- /docs/ui designs/3 home page (signed in).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/3 home page (signed in).png -------------------------------------------------------------------------------- /docs/ui designs/4 new post page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/4 new post page.png -------------------------------------------------------------------------------- /docs/ui designs/5 sign in page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/5 sign in page.png -------------------------------------------------------------------------------- /docs/ui designs/6 sign up page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/6 sign up page.png -------------------------------------------------------------------------------- /docs/ui designs/7 comments page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/7 comments page.png -------------------------------------------------------------------------------- /docs/ui designs/8 comments page (add comment).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/8 comments page (add comment).png -------------------------------------------------------------------------------- /docs/ui designs/9 user profile page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/9 user profile page.png -------------------------------------------------------------------------------- /docs/ui designs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/ui designs/favicon.ico -------------------------------------------------------------------------------- /docs/ui designs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/wireframes.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/docs/wireframes.jpeg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codersquare", 3 | "description": "A social web app for sharing learning resources in a hackernews-style experience.", 4 | "scripts": { 5 | "build:server": "cd server && npm i && npm run build", 6 | "build:shared": "cd shared && npm i && npm run build", 7 | "build:web": "cd web && npm i && npm run build", 8 | "build": "npm i && npm-run-all --parallel build:shared build:server build:web", 9 | "lint": "prettier . --write", 10 | "lint:check": "prettier . --check", 11 | "start:server": "cd server && npm start", 12 | "start:web": "cd web && npm start", 13 | "start": "npm-run-all --parallel start:server start:web" 14 | }, 15 | "devDependencies": { 16 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 17 | "npm-run-all": "^4.1.5", 18 | "prettier": "^2.7.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "npm i && tsc", 8 | "start": "touch /tmp/codersquare.sqlite && nodemon src/index.ts", 9 | "start:prod": "pm2 delete codersquare; pm2 start --name codersquare \"npx ts-node --transpile-only src/index.ts\"", 10 | "test": "jest", 11 | "test:watch": "jest --watch" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@codersquare/shared": "file:../shared", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.0.0", 19 | "express": "^4.17.2", 20 | "express-async-handler": "^1.2.0", 21 | "jsonwebtoken": "^8.5.1", 22 | "nodemon": "^2.0.19", 23 | "pino": "^8.8.0", 24 | "pino-pretty": "^9.1.1", 25 | "pm2": "^5.2.0", 26 | "sqlite": "^4.0.23", 27 | "sqlite3": "^5.1.1", 28 | "ts-node": "^10.9.1" 29 | }, 30 | "devDependencies": { 31 | "@types/cors": "^2.8.12", 32 | "@types/express": "^4.17.13", 33 | "@types/jest": "^28.1.8", 34 | "@types/jsonwebtoken": "^8.5.8", 35 | "@types/supertest": "^2.0.12", 36 | "jest": "^29.0.3", 37 | "supertest": "^6.2.4", 38 | "ts-jest": "^29.0.0", 39 | "typescript": "^4.8.3" 40 | }, 41 | "nodemonConfig": { 42 | "watch": [ 43 | ".", 44 | "../shared" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/src/.env.local: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | PASSWORD_SALT=helloworld 3 | JWT_SECRET=myjwtsecret 4 | ENV=development 5 | DB_PATH=/tmp/codersquare.sqlite 6 | -------------------------------------------------------------------------------- /server/src/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | 3 | import { getJwtSecret } from './env'; 4 | import { JwtObject } from './types'; 5 | 6 | export function signJwt(obj: JwtObject): string { 7 | return jwt.sign(obj, getJwtSecret(), { 8 | expiresIn: '15d', 9 | }); 10 | } 11 | 12 | // Throws one of VerifyErrors on bad tokens 13 | export function verifyJwt(token: string): JwtObject { 14 | return jwt.verify(token, getJwtSecret()) as JwtObject; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/datastore/dao/CommentDao.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '@codersquare/shared'; 2 | 3 | export interface CommentDao { 4 | createComment(comment: Comment): Promise; 5 | countComments(postId: string): Promise; 6 | listComments(postId: string): Promise; 7 | deleteComment(id: string): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/datastore/dao/LikeDao.ts: -------------------------------------------------------------------------------- 1 | import { Like } from '@codersquare/shared'; 2 | 3 | export interface LikeDao { 4 | createLike(like: Like): Promise; 5 | deleteLike(like: Like): Promise; 6 | getLikes(postId: string): Promise; 7 | exists(like: Like): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/datastore/dao/PostDao.ts: -------------------------------------------------------------------------------- 1 | import { Post } from '@codersquare/shared'; 2 | 3 | export interface PostDao { 4 | listPosts(userId?: string): Promise; 5 | createPost(post: Post): Promise; 6 | getPost(id: string, userId?: string): Promise; 7 | getPostByUrl(url: string): Promise; 8 | deletePost(id: string): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/datastore/dao/UserDao.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@codersquare/shared'; 2 | 3 | export interface UserDao { 4 | createUser(user: User): Promise; 5 | updateCurrentUser(user: Partial): Promise; 6 | getUserById(id: string): Promise; 7 | getUserByEmail(email: string): Promise; 8 | getUserByUsername(userName: string): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/datastore/index.ts: -------------------------------------------------------------------------------- 1 | import { CommentDao } from './dao/CommentDao'; 2 | import { LikeDao } from './dao/LikeDao'; 3 | import { PostDao } from './dao/PostDao'; 4 | import { UserDao } from './dao/UserDao'; 5 | import { SqlDataStore } from './sql'; 6 | 7 | export interface Datastore extends UserDao, PostDao, LikeDao, CommentDao {} 8 | 9 | export let db: Datastore; 10 | 11 | export async function initDb(dbPath: string) { 12 | db = await new SqlDataStore().openDb(dbPath); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/datastore/sql/index.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Like, Post, User } from '@codersquare/shared'; 2 | import path from 'path'; 3 | import { Database, open as sqliteOpen } from 'sqlite'; 4 | import sqlite3 from 'sqlite3'; 5 | 6 | import { Datastore } from '..'; 7 | import { LOGGER } from '../../logging'; 8 | import { SEED_POSTS, SEED_USERS } from './seeds'; 9 | 10 | export class SqlDataStore implements Datastore { 11 | private db!: Database; 12 | 13 | public async openDb(dbPath: string) { 14 | const { ENV } = process.env; 15 | 16 | // open the database 17 | try { 18 | LOGGER.info('Opening database file at:', dbPath); 19 | this.db = await sqliteOpen({ 20 | filename: dbPath, 21 | driver: sqlite3.Database, 22 | mode: sqlite3.OPEN_READWRITE, 23 | }); 24 | } catch (e) { 25 | LOGGER.error('Failed to open database at path:', dbPath, 'err:', e); 26 | process.exit(1); 27 | } 28 | 29 | this.db.run('PRAGMA foreign_keys = ON;'); 30 | 31 | await this.db.migrate({ 32 | migrationsPath: path.join(__dirname, 'migrations'), 33 | }); 34 | 35 | if (ENV === 'development') { 36 | LOGGER.info('Seeding data...'); 37 | 38 | SEED_USERS.forEach(async u => { 39 | if (!(await this.getUserById(u.id))) await this.createUser(u); 40 | }); 41 | SEED_POSTS.forEach(async p => { 42 | if (!(await this.getPostByUrl(p.url))) await this.createPost(p); 43 | }); 44 | } 45 | 46 | return this; 47 | } 48 | 49 | async createUser(user: User): Promise { 50 | await this.db.run( 51 | 'INSERT INTO users (id, email, password, firstName, lastName, userName) VALUES (?,?,?,?,?,?)', 52 | user.id, 53 | user.email, 54 | user.password, 55 | user.firstName, 56 | user.lastName, 57 | user.userName 58 | ); 59 | } 60 | 61 | async updateCurrentUser(user: User): Promise { 62 | await this.db.run( 63 | 'UPDATE users SET userName = ?, firstName = ?, lastName = ? WHERE id = ?', 64 | user.userName, 65 | user.firstName, 66 | user.lastName, 67 | user.id 68 | ); 69 | } 70 | getUserById(id: string): Promise { 71 | return this.db.get(`SELECT * FROM users WHERE id = ?`, id); 72 | } 73 | 74 | getUserByEmail(email: string): Promise { 75 | return this.db.get(`SELECT * FROM users WHERE email = ?`, email); 76 | } 77 | 78 | getUserByUsername(userName: string): Promise { 79 | return this.db.get(`SELECT * FROM users WHERE userName = ?`, userName); 80 | } 81 | 82 | listPosts(userId?: string): Promise { 83 | return this.db.all( 84 | `SELECT *, EXISTS( 85 | SELECT 1 FROM likes WHERE likes.postId = posts.id AND likes.userId = ? 86 | ) as liked FROM posts ORDER BY postedAt DESC`, 87 | userId 88 | ); 89 | } 90 | 91 | async createPost(post: Post): Promise { 92 | await this.db.run( 93 | 'INSERT INTO posts (id, title, url, postedAt, userId) VALUES (?,?,?,?,?)', 94 | post.id, 95 | post.title, 96 | post.url, 97 | post.postedAt, 98 | post.userId 99 | ); 100 | } 101 | 102 | async getPost(id: string, userId: string): Promise { 103 | return await this.db.get( 104 | `SELECT *, EXISTS( 105 | SELECT 1 FROM likes WHERE likes.postId = ? AND likes.userId = ? 106 | ) as liked FROM posts WHERE id = ?`, 107 | id, 108 | userId, 109 | id 110 | ); 111 | } 112 | 113 | async getPostByUrl(url: string): Promise { 114 | return await this.db.get(`SELECT * FROM posts WHERE url = ?`, url); 115 | } 116 | 117 | async deletePost(id: string): Promise { 118 | await this.db.run('Delete FROM posts WHERE id = ?', id); 119 | } 120 | 121 | async createLike(like: Like): Promise { 122 | await this.db.run('INSERT INTO likes(userId, postId) VALUES(?,?)', like.userId, like.postId); 123 | } 124 | 125 | async deleteLike(like: Like): Promise { 126 | await this.db.run( 127 | 'DELETE FROM likes WHERE userId = ? AND postId = ?', 128 | like.userId, 129 | like.postId 130 | ); 131 | } 132 | 133 | async createComment(comment: Comment): Promise { 134 | await this.db.run( 135 | 'INSERT INTO Comments(id, userId, postId, comment, postedAt) VALUES(?,?,?,?,?)', 136 | comment.id, 137 | comment.userId, 138 | comment.postId, 139 | comment.comment, 140 | comment.postedAt 141 | ); 142 | } 143 | 144 | async countComments(postId: string): Promise { 145 | const result = await this.db.get<{ count: number }>( 146 | 'SELECT COUNT(*) as count FROM comments WHERE postId = ?', 147 | postId 148 | ); 149 | return result?.count ?? 0; 150 | } 151 | 152 | async listComments(postId: string): Promise { 153 | return await this.db.all( 154 | 'SELECT * FROM comments WHERE postId = ? ORDER BY postedAt DESC', 155 | postId 156 | ); 157 | } 158 | 159 | async deleteComment(id: string): Promise { 160 | await this.db.run('DELETE FROM comments WHERE id = ?', id); 161 | } 162 | 163 | async getLikes(postId: string): Promise { 164 | let result = await this.db.get<{ count: number }>( 165 | 'SELECT COUNT(*) as count FROM likes WHERE postId = ?', 166 | postId 167 | ); 168 | return result?.count ?? 0; 169 | } 170 | 171 | async exists(like: Like): Promise { 172 | let awaitResult = await this.db.get( 173 | 'SELECT 1 FROM likes WHERE postId = ? and userId = ?', 174 | like.postId, 175 | like.userId 176 | ); 177 | let val: boolean = awaitResult === undefined ? false : true; 178 | return val; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /server/src/datastore/sql/migrations/001-initial.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id VARCHAR NOT NULL PRIMARY KEY, 3 | firstName VARCHAR NOT NULL, 4 | lastName VARCHAR NOT NULL, 5 | userName VARCHAR UNIQUE NOT NULL, 6 | email VARCHAR UNIQUE NOT NULL, 7 | password VARCHAR NOT NULL 8 | ); 9 | 10 | CREATE TABLE posts ( 11 | id VARCHAR NOT NULL PRIMARY KEY, 12 | title VARCHAR NOT NULL, 13 | url VARCHAR UNIQUE NOT NULL, 14 | userId VARCHAR NOT NULL, 15 | postedAt INTEGER NOT NULL, 16 | FOREIGN KEY (userId) REFERENCES users (id) 17 | ); 18 | -------------------------------------------------------------------------------- /server/src/datastore/sql/migrations/002-add-comment-likes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE comments ( 2 | id VARCHAR NOT NULL PRIMARY KEY, 3 | userId VARCHAR NOT NULL, 4 | postId VARCHAR NOT NULL, 5 | comment VARCHAR NOT NULL, 6 | postedAt INTEGER NOT NULL, 7 | FOREIGN KEY (userId) REFERENCES users (id), 8 | FOREIGN KEY (PostId) REFERENCES posts (id) 9 | ); 10 | 11 | CREATE TABLE likes ( 12 | userId VARCHAR NOT NULL, 13 | postId VARCHAR NOT NULL, 14 | FOREIGN KEY (userId) REFERENCES users (id), 15 | FOREIGN KEY (postId) REFERENCES posts (id), 16 | PRIMARY KEY (userId, postId) 17 | ); 18 | -------------------------------------------------------------------------------- /server/src/datastore/sql/seeds.ts: -------------------------------------------------------------------------------- 1 | import { Post, User } from '@codersquare/shared'; 2 | 3 | export const SEED_USERS: User[] = [ 4 | { 5 | email: 'user-bel-gebna@mail.com', 6 | id: 'user-bel-gebna', 7 | password: '123456', 8 | userName: 'gebna', 9 | firstName: 'User', 10 | lastName: 'Bel-Gebna', 11 | }, 12 | { 13 | email: '7amada-elgenn@mail.com', 14 | id: '7amada-elgenn-id', 15 | password: '123456', 16 | userName: '7amada', 17 | firstName: 'Hamada', 18 | lastName: 'Elgenn', 19 | }, 20 | { 21 | email: 'bolbol@mail.com', 22 | id: 'bolbol-7ayran', 23 | password: '123456', 24 | userName: 'bolbol', 25 | firstName: 'Bolbol', 26 | lastName: 'Hayran', 27 | }, 28 | { 29 | email: 'bororom@mail.com', 30 | id: 'isma3een-yaseen', 31 | password: '123456', 32 | userName: 'isma3een', 33 | firstName: 'Isma3een', 34 | lastName: 'Yaseen', 35 | }, 36 | ]; 37 | 38 | export const SEED_POSTS: Post[] = [ 39 | { 40 | id: 'post1-id', 41 | postedAt: new Date('Oct 12 2022 14:36:43').getTime(), 42 | title: 'FauxPilot - an open source Github Copilot server', 43 | url: 'http://github.com/moyix', 44 | userId: SEED_USERS[0].id, 45 | }, 46 | { 47 | id: 'post2-id', 48 | postedAt: new Date('Oct 11 2022 12:36:43').getTime(), 49 | title: 'Y Combinator narrows current cohort size by 40%, citing downturn and funding', 50 | url: 'http://techcrunch.com', 51 | userId: SEED_USERS[1].id, 52 | }, 53 | { 54 | id: 'post3-id', 55 | postedAt: new Date('Oct 7 2022 12:36:43').getTime(), 56 | title: 'Nonprofit markups.org is exposing the most egregious new car prices', 57 | url: 'http://themanual.com', 58 | userId: SEED_USERS[2].id, 59 | }, 60 | { 61 | id: 'post4-id', 62 | postedAt: new Date('Oct 5 2022 12:36:43').getTime(), 63 | title: 'RTEMS real time operating system', 64 | url: 'http://rtems.org', 65 | userId: SEED_USERS[3].id, 66 | }, 67 | { 68 | id: 'post5-id', 69 | postedAt: new Date('Oct 2 2022 12:36:43').getTime(), 70 | title: 'I used DALL-E 2 to generate a logo', 71 | url: 'http://jacobmartins.com', 72 | userId: SEED_USERS[1].id, 73 | }, 74 | ]; 75 | -------------------------------------------------------------------------------- /server/src/env.ts: -------------------------------------------------------------------------------- 1 | import { LOGGER } from './logging'; 2 | 3 | // Throws on bad tokens 4 | export function getJwtSecret(): string { 5 | const secret = process.env.JWT_SECRET; 6 | if (!secret) { 7 | LOGGER.error('Missing JWT secret'); 8 | process.exit(1); 9 | } 10 | return secret!; 11 | } 12 | 13 | export function getSalt(): string { 14 | const salt = process.env.PASSWORD_SALT; 15 | if (!salt) { 16 | LOGGER.error('Missing Password salt'); 17 | process.exit(1); 18 | } 19 | return salt!; 20 | } 21 | -------------------------------------------------------------------------------- /server/src/handlers/commentHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Comment, 3 | CountCommentsResponse, 4 | CreateCommentRequest, 5 | CreateCommentResponse, 6 | DeleteCommentResponse, 7 | ERRORS, 8 | ListCommentsResponse, 9 | } from '@codersquare/shared'; 10 | import crypto from 'crypto'; 11 | 12 | import { Datastore } from '../datastore'; 13 | import { ExpressHandlerWithParams } from '../types'; 14 | 15 | export class CommentHandler { 16 | private db: Datastore; 17 | 18 | constructor(db: Datastore) { 19 | this.db = db; 20 | } 21 | 22 | public create: ExpressHandlerWithParams< 23 | { postId: string }, 24 | CreateCommentRequest, 25 | CreateCommentResponse 26 | > = async (req, res) => { 27 | if (!req.params.postId) return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 28 | if (!req.body.comment) return res.status(400).send({ error: ERRORS.COMMENT_MISSING }); 29 | 30 | if (!(await this.db.getPost(req.params.postId, res.locals.userId))) { 31 | return res.status(404).send({ error: ERRORS.POST_NOT_FOUND }); 32 | } 33 | 34 | const commentForInsertion: Comment = { 35 | id: crypto.randomUUID(), 36 | postedAt: Date.now(), 37 | postId: req.params.postId, 38 | userId: res.locals.userId, 39 | comment: req.body.comment, 40 | }; 41 | await this.db.createComment(commentForInsertion); 42 | return res.sendStatus(200); 43 | }; 44 | 45 | public delete: ExpressHandlerWithParams<{ id: string }, null, DeleteCommentResponse> = async ( 46 | req, 47 | res 48 | ) => { 49 | if (!req.params.id) return res.status(404).send({ error: ERRORS.COMMENT_ID_MISSING }); 50 | await this.db.deleteComment(req.params.id); 51 | return res.sendStatus(200); 52 | }; 53 | 54 | public list: ExpressHandlerWithParams<{ postId: string }, null, ListCommentsResponse> = async ( 55 | req, 56 | res 57 | ) => { 58 | if (!req.params.postId) { 59 | return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 60 | } 61 | const comments = await this.db.listComments(req.params.postId); 62 | return res.send({ comments }); 63 | }; 64 | 65 | public count: ExpressHandlerWithParams<{ postId: string }, null, CountCommentsResponse> = async ( 66 | req, 67 | res 68 | ) => { 69 | if (!req.params.postId) { 70 | return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 71 | } 72 | const count = await this.db.countComments(req.params.postId); 73 | return res.send({ count }); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /server/src/handlers/likeHandler.ts: -------------------------------------------------------------------------------- 1 | import { ERRORS, Like, ListLikesResponse } from '@codersquare/shared'; 2 | 3 | import { Datastore } from '../datastore'; 4 | import { ExpressHandlerWithParams } from '../types'; 5 | 6 | export class LikeHandler { 7 | private db: Datastore; 8 | 9 | constructor(db: Datastore) { 10 | this.db = db; 11 | } 12 | 13 | public create: ExpressHandlerWithParams<{ postId: string }, null, {}> = async (req, res) => { 14 | if (!req.params.postId) { 15 | return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 16 | } 17 | if (!(await this.db.getPost(req.params.postId))) { 18 | return res.status(404).send({ error: ERRORS.POST_NOT_FOUND }); 19 | } 20 | 21 | let found = await this.db.exists({ 22 | postId: req.params.postId, 23 | userId: res.locals.userId, 24 | }); 25 | if (found) { 26 | return res.status(400).send({ error: ERRORS.DUPLICATE_LIKE }); 27 | } 28 | 29 | const likeForInsert: Like = { 30 | postId: req.params.postId, 31 | userId: res.locals.userId, 32 | }; 33 | 34 | this.db.createLike(likeForInsert); 35 | return res.sendStatus(200); 36 | }; 37 | 38 | public delete: ExpressHandlerWithParams<{ postId: string }, null, {}> = async (req, res) => { 39 | if (!req.params.postId) { 40 | return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 41 | } 42 | if (!(await this.db.getPost(req.params.postId))) { 43 | return res.status(404).send({ error: ERRORS.POST_NOT_FOUND }); 44 | } 45 | 46 | const likeForDelete: Like = { 47 | postId: req.params.postId, 48 | userId: res.locals.userId, 49 | }; 50 | 51 | this.db.deleteLike(likeForDelete); 52 | return res.sendStatus(200); 53 | }; 54 | 55 | public list: ExpressHandlerWithParams<{ postId: string }, null, ListLikesResponse> = async ( 56 | req, 57 | res 58 | ) => { 59 | if (!req.params.postId) { 60 | return res.status(400).send({ error: ERRORS.POST_ID_MISSING }); 61 | } 62 | const count: Number = await this.db.getLikes(req.params.postId); 63 | return res.send({ likes: count }); 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /server/src/handlers/postHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreatePostRequest, 3 | CreatePostResponse, 4 | DeletePostRequest, 5 | DeletePostResponse, 6 | ERRORS, 7 | GetPostResponse, 8 | ListPostsRequest, 9 | ListPostsResponse, 10 | Post, 11 | } from '@codersquare/shared'; 12 | import crypto from 'crypto'; 13 | 14 | import { Datastore } from '../datastore'; 15 | import { ExpressHandler, ExpressHandlerWithParams } from '../types'; 16 | 17 | export class PostHandler { 18 | private db: Datastore; 19 | 20 | constructor(db: Datastore) { 21 | this.db = db; 22 | } 23 | 24 | public list: ExpressHandler = async (_, res) => { 25 | // TODO: add pagination and filtering 26 | const userId = res.locals.userId; 27 | return res.send({ posts: await this.db.listPosts(userId) }); 28 | }; 29 | 30 | public create: ExpressHandler = async (req, res) => { 31 | // TODO: better error messages 32 | if (!req.body.title || !req.body.url) { 33 | return res.sendStatus(400); 34 | } 35 | 36 | const existing = await this.db.getPostByUrl(req.body.url); 37 | if (existing) { 38 | // A post with this url already exists 39 | return res.status(400).send({ error: ERRORS.DUPLICATE_URL }); 40 | } 41 | 42 | const post: Post = { 43 | id: crypto.randomUUID(), 44 | postedAt: Date.now(), 45 | title: req.body.title, 46 | url: req.body.url, 47 | userId: res.locals.userId, 48 | }; 49 | await this.db.createPost(post); 50 | return res.sendStatus(200); 51 | }; 52 | 53 | public delete: ExpressHandler = async (req, res) => { 54 | if (!req.body.postId) return res.sendStatus(400); 55 | this.db.deletePost(req.body.postId); 56 | return res.sendStatus(200); 57 | }; 58 | 59 | public get: ExpressHandlerWithParams<{ id: string }, null, GetPostResponse> = async ( 60 | req, 61 | res 62 | ) => { 63 | if (!req.params.id) return res.sendStatus(400); 64 | const postToReturn: Post | undefined = await this.db.getPost(req.params.id, res.locals.userId); 65 | if (!postToReturn) { 66 | return res.sendStatus(404); 67 | } 68 | return res.send({ post: postToReturn }); 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /server/src/handlers/userHandler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ERRORS, 3 | GetCurrentUserRequest, 4 | GetCurrentUserResponse, 5 | GetUserRequest, 6 | GetUserResponse, 7 | SignInRequest, 8 | SignInResponse, 9 | SignUpRequest, 10 | SignUpResponse, 11 | UpdateCurrentUserRequest, 12 | UpdateCurrentUserResponse, 13 | User, 14 | } from '@codersquare/shared'; 15 | import crypto from 'crypto'; 16 | 17 | import { signJwt } from '../auth'; 18 | import { Datastore } from '../datastore'; 19 | import { ExpressHandler, ExpressHandlerWithParams } from '../types'; 20 | 21 | export class UserHandler { 22 | private db: Datastore; 23 | 24 | constructor(db: Datastore) { 25 | this.db = db; 26 | } 27 | 28 | public signIn: ExpressHandler = async (req, res) => { 29 | const { login, password } = req.body; 30 | if (!login || !password) { 31 | return res.sendStatus(400); 32 | } 33 | 34 | const existing = 35 | (await this.db.getUserByEmail(login)) || (await this.db.getUserByUsername(login)); 36 | if (!existing || existing.password !== this.hashPassword(password)) { 37 | return res.sendStatus(403); 38 | } 39 | 40 | const jwt = signJwt({ userId: existing.id }); 41 | 42 | return res.status(200).send({ 43 | user: { 44 | email: existing.email, 45 | firstName: existing.firstName, 46 | lastName: existing.lastName, 47 | id: existing.id, 48 | userName: existing.userName, 49 | }, 50 | jwt, 51 | }); 52 | }; 53 | 54 | public signUp: ExpressHandler = async (req, res) => { 55 | const { email, firstName, lastName, password, userName } = req.body; 56 | if (!email || !userName || !password) { 57 | return res.status(400).send({ error: ERRORS.USER_REQUIRED_FIELDS }); 58 | } 59 | 60 | if (await this.db.getUserByEmail(email)) { 61 | return res.status(403).send({ error: ERRORS.DUPLICATE_EMAIL }); 62 | } 63 | if (await this.db.getUserByUsername(userName)) { 64 | return res.status(403).send({ error: ERRORS.DUPLICATE_USERNAME }); 65 | } 66 | 67 | const user: User = { 68 | id: crypto.randomUUID(), 69 | email, 70 | firstName: firstName ?? '', 71 | lastName: lastName ?? '', 72 | userName: userName, 73 | password: this.hashPassword(password), 74 | }; 75 | 76 | await this.db.createUser(user); 77 | const jwt = signJwt({ userId: user.id }); 78 | return res.status(200).send({ 79 | jwt, 80 | }); 81 | }; 82 | 83 | public get: ExpressHandlerWithParams<{ id: string }, GetUserRequest, GetUserResponse> = async ( 84 | req, 85 | res 86 | ) => { 87 | const { id } = req.params; 88 | if (!id) return res.sendStatus(400); 89 | 90 | const user = await this.db.getUserById(id); 91 | if (!user) { 92 | return res.sendStatus(404); 93 | } 94 | return res.send({ 95 | id: user.id, 96 | firstName: user.firstName, 97 | lastName: user.lastName, 98 | userName: user.userName, 99 | }); 100 | }; 101 | 102 | public getCurrent: ExpressHandler = async ( 103 | _, 104 | res 105 | ) => { 106 | const user = await this.db.getUserById(res.locals.userId); 107 | if (!user) { 108 | return res.sendStatus(500); 109 | } 110 | return res.send({ 111 | id: user.id, 112 | firstName: user.firstName, 113 | lastName: user.lastName, 114 | userName: user.userName, 115 | email: user.email, 116 | }); 117 | }; 118 | 119 | public updateCurrentUser: ExpressHandler = 120 | async (req, res) => { 121 | const currentUserId = res.locals.userId; 122 | const { userName } = req.body; 123 | 124 | if (userName && (await this.isDuplicateUserName(currentUserId, userName))) { 125 | return res.status(403).send({ error: ERRORS.DUPLICATE_USERNAME }); 126 | } 127 | 128 | const currentUser = await this.db.getUserById(currentUserId); 129 | if (!currentUser) { 130 | return res.status(404).send({ error: ERRORS.USER_NOT_FOUND }); 131 | } 132 | 133 | await this.db.updateCurrentUser({ 134 | id: currentUserId, 135 | userName: userName ?? currentUser.userName, 136 | firstName: req.body.firstName ?? currentUser.firstName, 137 | lastName: req.body.lastName ?? currentUser.lastName, 138 | }); 139 | return res.sendStatus(200); 140 | }; 141 | 142 | private async isDuplicateUserName(currentUserId: string, newUserName: string): Promise { 143 | const userWithProvidedUserName = await this.db.getUserByUsername(newUserName); 144 | // returns true if we have a user with this userName and it's not the authenticated user 145 | return (userWithProvidedUserName != undefined) && (userWithProvidedUserName.id !== currentUserId); 146 | } 147 | 148 | private hashPassword(password: string): string { 149 | return crypto 150 | .pbkdf2Sync(password, process.env.PASSWORD_SALT!, 42, 64, 'sha512') 151 | .toString('hex'); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | import { LOGGER } from './logging'; 4 | import { createServer } from './server'; 5 | 6 | (async () => { 7 | // read .env file 8 | dotenv.config(); 9 | 10 | const { ENV, PORT } = process.env; 11 | if (!ENV || !PORT) { 12 | LOGGER.error('Missing some required env vars'); 13 | process.exit(1); 14 | } 15 | 16 | const server = await createServer(); 17 | server.listen(PORT, () => LOGGER.info(`Listening on port ${PORT} in ${ENV} environment`)); 18 | })(); 19 | -------------------------------------------------------------------------------- /server/src/logging.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { pino } from 'pino'; 4 | import pretty from 'pino-pretty'; 5 | 6 | const streams: { write: any }[] = [ 7 | process.env.ENV === 'production' ? process.stdout : pretty(), 8 | fs.createWriteStream(path.join(__dirname, '..', 'process.log')), 9 | ]; 10 | 11 | export const LOGGER = pino( 12 | { 13 | redact: ['body.password'], 14 | formatters: { 15 | bindings: () => ({}), 16 | }, 17 | }, 18 | pino.multistream(streams) 19 | ); 20 | -------------------------------------------------------------------------------- /server/src/middleware/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { ERRORS } from '@codersquare/shared'; 2 | import { TokenExpiredError, VerifyErrors } from 'jsonwebtoken'; 3 | 4 | import { verifyJwt } from '../auth'; 5 | import { db } from '../datastore'; 6 | import { ExpressHandler, JwtObject } from '../types'; 7 | 8 | export const jwtParseMiddleware: ExpressHandler = async (req, res, next) => { 9 | const token = req.headers.authorization?.split(' ')[1]; 10 | if (!token) { 11 | return next(); 12 | } 13 | 14 | let payload: JwtObject; 15 | try { 16 | payload = verifyJwt(token); 17 | } catch (e) { 18 | const verifyErr = e as VerifyErrors; 19 | if (verifyErr instanceof TokenExpiredError) { 20 | return res.status(401).send({ error: ERRORS.TOKEN_EXPIRED }); 21 | } 22 | return res.status(401).send({ error: ERRORS.BAD_TOKEN }); 23 | } 24 | 25 | const user = await db.getUserById(payload.userId); 26 | if (!user) { 27 | return res.status(401).send({ error: ERRORS.USER_NOT_FOUND }); 28 | } 29 | res.locals.userId = user.id; 30 | return next(); 31 | }; 32 | 33 | export const enforceJwtMiddleware: ExpressHandler = async (_, res, next) => { 34 | if (!res.locals.userId) { 35 | return res.sendStatus(401); 36 | } 37 | return next(); 38 | }; 39 | -------------------------------------------------------------------------------- /server/src/middleware/errorMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { ErrorRequestHandler } from 'express'; 2 | 3 | import { LOGGER } from '../logging'; 4 | 5 | export const errHandler: ErrorRequestHandler = (err, _, res, __) => { 6 | LOGGER.error('Uncaught exception:', err); 7 | return res.status(500).send('Oops, an unexpected error occurred, please try again'); 8 | }; 9 | -------------------------------------------------------------------------------- /server/src/middleware/loggerMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | import { LOGGER } from '../logging'; 4 | 5 | export const loggerMiddleware: RequestHandler = (req, _, next) => { 6 | LOGGER.info({ 7 | method: req.method, 8 | path: req.path, 9 | body: Object.keys(req.body).length ? req.body : undefined, 10 | }); 11 | next(); 12 | }; 13 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import { ENDPOINT_CONFIGS, Endpoints } from '@codersquare/shared'; 2 | import cors from 'cors'; 3 | import express, { RequestHandler } from 'express'; 4 | import asyncHandler from 'express-async-handler'; 5 | 6 | import { db, initDb } from './datastore'; 7 | import { CommentHandler } from './handlers/commentHandler'; 8 | import { LikeHandler } from './handlers/likeHandler'; 9 | import { PostHandler } from './handlers/postHandler'; 10 | import { UserHandler } from './handlers/userHandler'; 11 | import { LOGGER } from './logging'; 12 | import { enforceJwtMiddleware, jwtParseMiddleware } from './middleware/authMiddleware'; 13 | import { errHandler } from './middleware/errorMiddleware'; 14 | import { loggerMiddleware } from './middleware/loggerMiddleware'; 15 | 16 | export async function createServer(logRequests = true) { 17 | const dbPath = process.env.DB_PATH; 18 | if (!dbPath) { 19 | LOGGER.error('DB_PATH env var missing'); 20 | process.exit(1); 21 | } 22 | await initDb(dbPath); 23 | 24 | // create express app 25 | const app = express(); 26 | 27 | // middlewares for parsing JSON payloads and opening up cors policy 28 | app.use(express.json()); 29 | app.use(cors()); 30 | 31 | if (logRequests) { 32 | // log incoming Requests 33 | app.use(loggerMiddleware); 34 | } 35 | 36 | const userHandler = new UserHandler(db); 37 | const postHandler = new PostHandler(db); 38 | const likeHandler = new LikeHandler(db); 39 | const commentHandler = new CommentHandler(db); 40 | 41 | // map of endpoints handlers 42 | const HANDLERS: { [key in Endpoints]: RequestHandler } = { 43 | [Endpoints.healthz]: (_, res) => res.send({ status: 'ok!' }), 44 | 45 | [Endpoints.signin]: userHandler.signIn, 46 | [Endpoints.signup]: userHandler.signUp, 47 | [Endpoints.getUser]: userHandler.get, 48 | [Endpoints.getCurrentUser]: userHandler.getCurrent, 49 | [Endpoints.updateCurrentUser]: userHandler.updateCurrentUser, 50 | 51 | [Endpoints.listPosts]: postHandler.list, 52 | [Endpoints.getPost]: postHandler.get, 53 | [Endpoints.createPost]: postHandler.create, 54 | [Endpoints.deletePost]: postHandler.delete, 55 | 56 | [Endpoints.listLikes]: likeHandler.list, 57 | [Endpoints.createLike]: likeHandler.create, 58 | [Endpoints.deleteLike]: likeHandler.delete, 59 | 60 | [Endpoints.countComments]: commentHandler.count, 61 | [Endpoints.listComments]: commentHandler.list, 62 | [Endpoints.createComment]: commentHandler.create, 63 | [Endpoints.deleteComment]: commentHandler.delete, 64 | }; 65 | 66 | // register handlers in express 67 | Object.keys(Endpoints).forEach(entry => { 68 | const config = ENDPOINT_CONFIGS[entry as Endpoints]; 69 | const handler = HANDLERS[entry as Endpoints]; 70 | 71 | config.auth 72 | ? app[config.method]( 73 | config.url, 74 | jwtParseMiddleware, 75 | enforceJwtMiddleware, 76 | asyncHandler(handler) 77 | ) 78 | : app[config.method](config.url, jwtParseMiddleware, asyncHandler(handler)); 79 | }); 80 | 81 | app.use(errHandler); 82 | 83 | // start server, https in production, otherwise http. 84 | const { ENV } = process.env; 85 | 86 | if (!ENV) { 87 | throw 'Environment not defined, make sure to pass in env vars or have a .env file at root.'; 88 | } 89 | 90 | return app; 91 | } 92 | -------------------------------------------------------------------------------- /server/src/test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { ENDPOINT_CONFIGS } from '@codersquare/shared'; 2 | import supertest from 'supertest'; 3 | 4 | import { getTestServer } from './testserver'; 5 | 6 | describe('integration test', () => { 7 | let client: supertest.SuperTest; 8 | 9 | const email = 'user@mail.com'; 10 | const userName = 'u'; 11 | const password = '123'; 12 | 13 | beforeAll(async () => { 14 | client = await getTestServer(); 15 | }); 16 | 17 | it('/healthz works', async () => { 18 | const result = await client.get(ENDPOINT_CONFIGS.healthz.url).expect(200); 19 | expect(result.body).toStrictEqual({ status: 'ok!' }); 20 | }); 21 | 22 | it('signs up a new user', async () => { 23 | const { method, url } = ENDPOINT_CONFIGS.signup; 24 | const result = await client[method](url) 25 | .send({ 26 | email, 27 | userName, 28 | password, 29 | }) 30 | .expect(200); 31 | expect(result.body.jwt).toBeDefined(); 32 | }); 33 | 34 | it('fails signup without required fields', async () => { 35 | const { method, url } = ENDPOINT_CONFIGS.signup; 36 | await client[method](url) 37 | .send({ 38 | firstName: 'fname', 39 | lastName: 'lname', 40 | userName, 41 | password, 42 | }) 43 | .expect(400); 44 | }); 45 | 46 | it('can log in with username', async () => { 47 | const { method, url } = ENDPOINT_CONFIGS.signin; 48 | const result = await client[method](url).send({ login: userName, password }).expect(200); 49 | expect(result.body.jwt).toBeDefined(); 50 | }); 51 | 52 | it('can log in with email', async () => { 53 | const { method, url } = ENDPOINT_CONFIGS.signin; 54 | const result = await client[method](url).send({ login: email, password }).expect(200); 55 | expect(result.body.jwt).toBeDefined(); 56 | }); 57 | 58 | it('creates a post and returns it in list and get', async () => { 59 | const postTitle = 'first post'; 60 | const postUrl = 'firstpost.com'; 61 | 62 | { 63 | const { method, url } = ENDPOINT_CONFIGS.createPost; 64 | await client[method](url) 65 | .send({ title: postTitle, url: postUrl }) 66 | .set(await getAuthToken()) 67 | .expect(200); 68 | } 69 | 70 | let postId: string; 71 | { 72 | const { method, url } = ENDPOINT_CONFIGS.listPosts; 73 | const postList = await client[method](url).expect(200); 74 | expect(postList.body.posts).toHaveLength(1); 75 | expect(postList.body.posts[0].title).toBe(postTitle); 76 | expect(postList.body.posts[0].url).toBe(postUrl); 77 | postId = postList.body.posts[0].id; 78 | } 79 | 80 | { 81 | const { method, url } = ENDPOINT_CONFIGS.getPost; 82 | const post = await client[method](url.replace(':id', postId)).expect(200); 83 | expect(post.body.post.id).toBe(postId); 84 | expect(post.body.post.title).toBe(postTitle); 85 | expect(post.body.post.url).toBe(postUrl); 86 | } 87 | }); 88 | 89 | it('fails to create post with duplicate URL', async () => { 90 | const postUrl = 'unique-post.com'; 91 | 92 | { 93 | const { method, url } = ENDPOINT_CONFIGS.createPost; 94 | await client[method](url) 95 | .send({ title: 'first post', url: postUrl }) 96 | .set(await getAuthToken()) 97 | .expect(200); 98 | } 99 | 100 | { 101 | const { method, url } = ENDPOINT_CONFIGS.createPost; 102 | await client[method](url) 103 | .send({ title: 'second post', url: postUrl }) 104 | .set(await getAuthToken()) 105 | .expect(400); 106 | } 107 | }); 108 | 109 | it('likes first post, cannot like twice', async () => { 110 | const postId = await get1stPost(); 111 | const { method, url } = ENDPOINT_CONFIGS.createLike; 112 | await client[method](url.replace(':postId', postId)) 113 | .set(await getAuthToken()) 114 | .expect(200); 115 | 116 | // Second time should fail 117 | await client[method](url.replace(':postId', postId)) 118 | .set(await getAuthToken()) 119 | .expect(400); 120 | }); 121 | 122 | it('lists number of likes', async () => { 123 | const postId = await get1stPost(); 124 | const { method, url } = ENDPOINT_CONFIGS.listLikes; 125 | const result = await client[method](url.replace(':postId', postId)).expect(200); 126 | expect(result.body.likes).toBe(1); 127 | }); 128 | 129 | it('comments on post, multiple times, returns list of comments, deletes 1st comment', async () => { 130 | const olderComment = 'this is the first comment'; 131 | const newerComment = 'this is the second comment'; 132 | 133 | const postId = await get1stPost(); 134 | { 135 | const { method, url } = ENDPOINT_CONFIGS.createComment; 136 | await client[method](url.replace(':postId', postId)) 137 | .set(await getAuthToken()) 138 | .send({ comment: olderComment }) 139 | .expect(200); 140 | await client[method](url.replace(':postId', postId)) 141 | .set(await getAuthToken()) 142 | .send({ comment: newerComment }) 143 | .expect(200); 144 | } 145 | 146 | let commentId: string; 147 | { 148 | const { method, url } = ENDPOINT_CONFIGS.listComments; 149 | const results = await client[method](url.replace(':postId', postId)).expect(200); 150 | expect(results.body.comments).toHaveLength(2); 151 | expect(results.body.comments[0].comment).toBe(newerComment); 152 | expect(results.body.comments[1].comment).toBe(olderComment); 153 | commentId = results.body.comments[0].id; 154 | } 155 | 156 | { 157 | const { method, url } = ENDPOINT_CONFIGS.deleteComment; 158 | await client[method](url.replace(':id', commentId)) 159 | .set(await getAuthToken()) 160 | .expect(200); 161 | } 162 | 163 | { 164 | const { method, url } = ENDPOINT_CONFIGS.listComments; 165 | const results = await client[method](url.replace(':postId', postId)).expect(200); 166 | expect(results.body.comments).toHaveLength(1); 167 | } 168 | }); 169 | 170 | const getAuthToken = async () => { 171 | const { method, url } = ENDPOINT_CONFIGS.signin; 172 | const result = await client[method](url).send({ login: email, password }).expect(200); 173 | return { Authorization: 'Bearer ' + result.body.jwt }; 174 | }; 175 | 176 | const get1stPost = async () => { 177 | const { method, url } = ENDPOINT_CONFIGS.listPosts; 178 | const postList = await client[method](url).expect(200); 179 | return postList.body.posts[0].id; 180 | }; 181 | }); 182 | -------------------------------------------------------------------------------- /server/src/test/testserver.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import path from 'path'; 3 | import superset from 'supertest'; 4 | 5 | import { createServer } from '../server'; 6 | 7 | let client: superset.SuperTest; 8 | 9 | export async function getTestServer() { 10 | if (!client) { 11 | config({ path: path.join(__dirname, '/.env.test') }); 12 | const server = await createServer(false); 13 | client = superset(server); 14 | } 15 | 16 | return client; 17 | } 18 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | 3 | // Create generic type and append error prop to the Type T 4 | type WithError = T & { error: string }; 5 | 6 | export type ExpressHandler = RequestHandler< 7 | string, 8 | Partial>, 9 | Partial, 10 | any 11 | >; 12 | 13 | export type ExpressHandlerWithParams = RequestHandler< 14 | Partial, 15 | Partial>, 16 | Partial, 17 | any 18 | >; 19 | 20 | export interface JwtObject { 21 | userId: string; 22 | } 23 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "jsx": "react-jsx" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src/types'; 2 | export * from './src/api'; 3 | export * from './src/endpoints'; 4 | export * from './src/errors'; 5 | -------------------------------------------------------------------------------- /shared/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codersquare/shared", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "@codersquare/shared", 8 | "devDependencies": { 9 | "typescript": "^4.7.4" 10 | } 11 | }, 12 | "node_modules/typescript": { 13 | "version": "4.7.4", 14 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", 15 | "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", 16 | "dev": true, 17 | "bin": { 18 | "tsc": "bin/tsc", 19 | "tsserver": "bin/tsserver" 20 | }, 21 | "engines": { 22 | "node": ">=4.2.0" 23 | } 24 | } 25 | }, 26 | "dependencies": { 27 | "typescript": { 28 | "version": "4.7.4", 29 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", 30 | "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", 31 | "dev": true 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codersquare/shared", 3 | "scripts": { 4 | "build": "npm i && tsc" 5 | }, 6 | "devDependencies": { 7 | "typescript": "^4.7.4" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /shared/src/api.ts: -------------------------------------------------------------------------------- 1 | import { Comment, Post, User } from './types'; 2 | 3 | // Post APIs 4 | export interface ListPostsRequest {} 5 | export interface ListPostsResponse { 6 | posts: Post[]; 7 | } 8 | 9 | export type CreatePostRequest = Pick; 10 | export type DeletePostRequest = { postId: string }; 11 | export type DeletePostResponse = {}; 12 | export interface CreatePostResponse {} 13 | export type GetPostRequest = { postId: string }; 14 | export interface GetPostResponse { 15 | post: Post; 16 | } 17 | 18 | // Comment APIs 19 | export type CreateCommentRequest = Pick; 20 | export interface CreateCommentResponse {} 21 | export type CountCommentsRequest = { postId: string }; 22 | export type CountCommentsResponse = { count: number }; 23 | 24 | export interface ListCommentsResponse { 25 | comments: Comment[]; 26 | } 27 | 28 | export type DeleteCommentResponse = {}; 29 | 30 | // Like APIs 31 | export interface ListLikesResponse { 32 | likes: Number; 33 | } 34 | 35 | // User APIs 36 | export type SignUpRequest = Pick< 37 | User, 38 | 'email' | 'firstName' | 'lastName' | 'userName' | 'password' 39 | >; 40 | export interface SignUpResponse { 41 | jwt: string; 42 | } 43 | 44 | export interface SignInRequest { 45 | login: string; // userName or email 46 | password: string; 47 | } 48 | export type SignInResponse = { 49 | user: Pick; 50 | jwt: string; 51 | }; 52 | 53 | export type GetUserRequest = {}; 54 | export type GetUserResponse = Pick; 55 | 56 | export type GetCurrentUserRequest = {}; 57 | export type GetCurrentUserResponse = Pick< 58 | User, 59 | 'id' | 'firstName' | 'lastName' | 'userName' | 'email' 60 | >; 61 | 62 | export type UpdateCurrentUserRequest = Partial>; 63 | export type UpdateCurrentUserResponse = {}; 64 | 65 | export type GetUserByEmailRequest = { emailId: string }; 66 | export interface GetUserByEmailResponse { 67 | user: User; 68 | } 69 | export type GetUserByUserNameRequest = { 70 | userName: string; 71 | }; 72 | export interface GetUserByUserNameResponse { 73 | user: User; 74 | } 75 | -------------------------------------------------------------------------------- /shared/src/endpoints.ts: -------------------------------------------------------------------------------- 1 | export type EndpointConfig = { 2 | url: string; 3 | method: 'patch' | 'get' | 'post' | 'delete'; 4 | auth?: boolean; 5 | sensitive?: boolean; // Skips logging request body 6 | }; 7 | 8 | export enum Endpoints { 9 | healthz = 'healthz', 10 | 11 | signin = 'signin', 12 | signup = 'signup', 13 | getUser = 'getUser', 14 | getCurrentUser = 'getCurrentUser', 15 | updateCurrentUser = 'updateCurrentUser', 16 | 17 | listPosts = 'listPosts', 18 | getPost = 'getPost', 19 | createPost = 'createPost', 20 | deletePost = 'deletePost', 21 | 22 | listLikes = 'listLikes', 23 | createLike = 'createLike', 24 | deleteLike = 'deleteLike', 25 | 26 | countComments = 'countComments', 27 | listComments = 'listComments', 28 | createComment = 'createComment', 29 | deleteComment = 'deleteComment', 30 | } 31 | 32 | export function withParams(endpoint: EndpointConfig, ...params: string[]): EndpointConfig { 33 | let url = endpoint.url; 34 | const placeholders = url.match(/:[^\/]*/g) || []; 35 | if (placeholders.length !== params.length) { 36 | throw `Too ${placeholders.length < params.length ? 'many' : 'few'} params for url: ${url}!`; 37 | } 38 | for (let index = 0; index < params.length; index++) { 39 | url = url.replace(placeholders[index], params[index]); 40 | } 41 | return { 42 | url: url, 43 | method: endpoint.method, 44 | auth: endpoint.auth, 45 | } as EndpointConfig; 46 | } 47 | 48 | export const ENDPOINT_CONFIGS: { [key in Endpoints]: EndpointConfig } = { 49 | [Endpoints.healthz]: { method: 'get', url: '/api/v1/healthz' }, 50 | 51 | [Endpoints.signin]: { method: 'post', url: '/api/v1/signin', sensitive: true }, 52 | [Endpoints.signup]: { method: 'post', url: '/api/v1/signup', sensitive: true }, 53 | [Endpoints.getUser]: { method: 'get', url: '/api/v1/users/:id' }, 54 | [Endpoints.getCurrentUser]: { method: 'get', url: '/api/v1/users', auth: true }, 55 | [Endpoints.updateCurrentUser]: { method: 'patch', url: '/api/v1/users', auth: true }, 56 | 57 | [Endpoints.listPosts]: { method: 'get', url: '/api/v1/posts' }, 58 | [Endpoints.getPost]: { method: 'get', url: '/api/v1/posts/:id' }, 59 | [Endpoints.createPost]: { method: 'post', url: '/api/v1/posts', auth: true }, 60 | [Endpoints.deletePost]: { method: 'delete', url: '/api/v1/posts/:id', auth: true }, 61 | 62 | [Endpoints.listLikes]: { method: 'get', url: '/api/v1/likes/:postId' }, 63 | [Endpoints.createLike]: { method: 'post', url: '/api/v1/likes/:postId', auth: true }, 64 | [Endpoints.deleteLike]: { method: 'delete', url: '/api/v1/likes/:postId', auth: true }, 65 | 66 | [Endpoints.countComments]: { method: 'get', url: '/api/v1/comments/:postId/count' }, 67 | [Endpoints.listComments]: { method: 'get', url: '/api/v1/comments/:postId' }, 68 | [Endpoints.createComment]: { method: 'post', url: '/api/v1/comments/:postId', auth: true }, 69 | [Endpoints.deleteComment]: { method: 'delete', url: '/api/v1/comments/:id', auth: true }, 70 | }; 71 | -------------------------------------------------------------------------------- /shared/src/errors.ts: -------------------------------------------------------------------------------- 1 | export enum ERRORS { 2 | TOKEN_EXPIRED = 'Token expired', 3 | BAD_TOKEN = 'Bad token', 4 | 5 | USER_NOT_FOUND = 'User not found', 6 | USER_REQUIRED_FIELDS = 'Email, username, and password are required', 7 | DUPLICATE_EMAIL = 'An account with this email already exists', 8 | DUPLICATE_USERNAME = 'An account with this username already exists', 9 | 10 | POST_ID_MISSING = 'Post ID is missing', 11 | POST_NOT_FOUND = 'Post not found', 12 | DUPLICATE_URL = 'A post with this URL already exists', 13 | 14 | COMMENT_MISSING = 'Comment is missing', 15 | COMMENT_ID_MISSING = 'Comment ID is missing', 16 | 17 | DUPLICATE_LIKE = 'Duplicate like', 18 | } 19 | -------------------------------------------------------------------------------- /shared/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | firstName?: string; 4 | lastName?: string; 5 | userName: string; 6 | email: string; 7 | password: string; 8 | } 9 | 10 | export interface Post { 11 | id: string; 12 | title: string; 13 | url: string; 14 | userId: string; 15 | postedAt: number; 16 | liked?: boolean; 17 | } 18 | 19 | export interface Like { 20 | userId: string; 21 | postId: string; 22 | } 23 | 24 | export interface Comment { 25 | id: string; 26 | userId: string; 27 | postId: string; 28 | comment: string; 29 | postedAt: number; 30 | liked?: boolean; 31 | } 32 | -------------------------------------------------------------------------------- /shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /web/craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = { 3 | plugins: [ 4 | { 5 | plugin: { 6 | overrideWebpackConfig: ({ webpackConfig }) => { 7 | const oneOfRule = webpackConfig.module.rules.find(rule => rule.oneOf); 8 | if (oneOfRule) { 9 | const tsxRule = oneOfRule.oneOf.find( 10 | rule => rule.test && rule.test.toString().includes('tsx') 11 | ); 12 | 13 | const newIncludePaths = [ 14 | // relative path to the shared module 15 | path.resolve(__dirname, '../shared'), 16 | ]; 17 | if (tsxRule) { 18 | if (Array.isArray(tsxRule.include)) { 19 | tsxRule.include = [...tsxRule.include, ...newIncludePaths]; 20 | } else { 21 | tsxRule.include = [tsxRule.include, ...newIncludePaths]; 22 | } 23 | } 24 | } 25 | return webpackConfig; 26 | }, 27 | }, 28 | }, 29 | ], 30 | }; 31 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/react": "^2.3.1", 7 | "@codersquare/shared": "file:../shared", 8 | "@craco/craco": "^7.0.0-alpha.7", 9 | "@emotion/react": "^11.10.4", 10 | "@emotion/styled": "^11.10.4", 11 | "@tanstack/react-query": "^4.1.3", 12 | "@types/node": "^16.11.48", 13 | "@types/react": "^18.0.17", 14 | "@types/react-dom": "^18.0.6", 15 | "date-fns": "^2.29.2", 16 | "framer-motion": "^7.2.1", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-icons": "^4.4.0", 20 | "react-linkify-it": "^1.0.7", 21 | "react-router-dom": "^6.3.0", 22 | "react-scripts": "^5.0.1", 23 | "typescript": "^4.7.4" 24 | }, 25 | "devDependencies": {}, 26 | "scripts": { 27 | "start": "BROWSER=NONE REACT_APP_ENV=development REACT_APP_PORT=3000 craco start", 28 | "build": "GENERATE_SOURCEMAP=false NODE_OPTIONS=--max_old_space_size=4096 craco --max_old_space_size=4096 build" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "proxy": "http://localhost:3001" 49 | } 50 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yebrahim/codersquare/ab1399cec4865c584f38971dd120b7bf9bedaa16/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Codersquare 15 | 16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /web/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/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 | "src": "logo.svg", 12 | "type": "image/svg", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo.svg", 17 | "type": "image/svg", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@chakra-ui/react'; 2 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 3 | 4 | import { Navbar } from './components/navbar'; 5 | import { CurrentUserContextProvider } from './contexts/userContext'; 6 | import { ListPosts } from './pages/list-posts'; 7 | import { NewPost } from './pages/new-post'; 8 | import { SignIn } from './pages/sign-in'; 9 | import { SignUp } from './pages/sign-up'; 10 | import { UserProfile } from './pages/user-profile'; 11 | import { ViewPost } from './pages/view-post'; 12 | import { ROUTES } from './routes'; 13 | import { isDev } from './util'; 14 | 15 | export const App = () => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | } /> 25 | } /> 26 | } /> 27 | } /> 28 | } /> 29 | } /> 30 | 31 | 32 | 33 | 34 | 35 | {isDev && ( 36 | 37 | development version 38 | 39 | )} 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /web/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/src/components/comment-card.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Icon, Text } from '@chakra-ui/react'; 2 | import { 3 | Comment, 4 | ENDPOINT_CONFIGS, 5 | GetUserRequest, 6 | GetUserResponse, 7 | withParams, 8 | } from '@codersquare/shared'; 9 | import { useQuery } from '@tanstack/react-query'; 10 | import formatDistance from 'date-fns/formatDistance'; 11 | import React from 'react'; 12 | import { BsHeart } from 'react-icons/bs'; 13 | import { LinkItUrl } from 'react-linkify-it'; 14 | import { Link } from 'react-router-dom'; 15 | 16 | import { callEndpoint } from '../fetch'; 17 | import { isLoggedIn } from '../fetch/auth'; 18 | import { ROUTES } from '../routes'; 19 | 20 | export const CommentCard: React.FC<{ comment: Comment }> = ({ comment }) => { 21 | const { comment: commentText, postedAt, userId } = comment; 22 | const { 23 | data: user, 24 | error, 25 | isLoading, 26 | } = useQuery([`getuser${userId}`], () => 27 | callEndpoint(withParams(ENDPOINT_CONFIGS.getUser, userId)) 28 | ); 29 | 30 | const userName = isLoading || !user ? '...' : error ? '' : user.userName; 31 | 32 | return ( 33 | 34 | 35 | {isLoggedIn() && ( 36 | 37 | 45 | 46 | )} 47 | 48 | By: 49 | 50 | 51 | 52 | {userName} 53 | 54 | 55 | 56 | {formatDistance(postedAt, Date.now(), { addSuffix: true })} 57 | 58 | 59 | 60 | 61 | 62 | {commentText} 63 | 64 | 65 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /web/src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Image, Text } from '@chakra-ui/react'; 2 | import { useCallback } from 'react'; 3 | import { Link, useNavigate } from 'react-router-dom'; 4 | 5 | import Logo from '../assets/logo.svg'; 6 | import { useCurrentUser } from '../contexts/userContext'; 7 | import { isLoggedIn, signOut } from '../fetch/auth'; 8 | import { ROUTES } from '../routes'; 9 | 10 | export const Navbar = () => { 11 | const navigate = useNavigate(); 12 | const { currentUser, refreshCurrentUser } = useCurrentUser(); 13 | const onSignout = useCallback(() => { 14 | signOut(); 15 | refreshCurrentUser(); 16 | navigate(ROUTES.HOME); 17 | }, [navigate, refreshCurrentUser]); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | {isLoggedIn() ? ( 27 | <> 28 | 29 | 32 | 33 | {currentUser && ( 34 | 35 | 36 | {currentUser.userName} 37 | 38 | 39 | )} 40 | 43 | 44 | ) : ( 45 | <> 46 | 47 | 50 | 51 | 52 | 55 | 56 | 57 | )} 58 | 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /web/src/components/post-card.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Icon, Text } from '@chakra-ui/react'; 2 | import { 3 | CountCommentsRequest, 4 | CountCommentsResponse, 5 | ENDPOINT_CONFIGS, 6 | GetUserRequest, 7 | GetUserResponse, 8 | Post, 9 | withParams, 10 | } from '@codersquare/shared'; 11 | import { useQuery } from '@tanstack/react-query'; 12 | import formatDistance from 'date-fns/formatDistance'; 13 | import React, { useCallback } from 'react'; 14 | import { BsHeart, BsHeartFill } from 'react-icons/bs'; 15 | import { Link } from 'react-router-dom'; 16 | 17 | import { callEndpoint } from '../fetch'; 18 | import { isLoggedIn } from '../fetch/auth'; 19 | import { ROUTES } from '../routes'; 20 | 21 | export const PostCard: React.FC<{ post: Post; refetch: () => unknown; hideDiscuss?: boolean }> = ({ 22 | post, 23 | refetch, 24 | hideDiscuss, 25 | }) => { 26 | const { id, url: postUrl, title, userId, liked } = post; 27 | const { user, error, isLoading } = useGetUser(userId); 28 | const { countCommentsRes } = useCountComments(id); 29 | 30 | const urlWithProtocol = postUrl.startsWith('http') ? postUrl : 'http://' + postUrl; 31 | const userName = isLoading || !user ? '...' : error ? '' : user.userName; 32 | const commentsCount = countCommentsRes?.count ?? 0; 33 | 34 | const toggleLike = useCallback( 35 | async (postId: string, like: boolean) => { 36 | const endpoint = like ? ENDPOINT_CONFIGS.createLike : ENDPOINT_CONFIGS.deleteLike; 37 | await callEndpoint<{}, {}>(withParams(endpoint, postId)); 38 | refetch(); 39 | }, 40 | [refetch] 41 | ); 42 | 43 | return ( 44 | 45 | {isLoggedIn() && ( 46 | toggleLike(id, !liked)}> 47 | 55 | 56 | )} 57 | 58 | 59 | 60 | 61 | 62 | {title} 63 | 64 | 65 | 66 | 67 | 68 | ({getUrlDomain(urlWithProtocol)}) 69 | 70 | 71 | 72 | {!hideDiscuss && ( 73 | 74 | 87 | 88 | )} 89 | 90 | 91 | 92 | By: 93 | 94 | {userName} 95 | 96 | 97 | - {formatDistance(post.postedAt, Date.now(), { addSuffix: true })} 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | const getUrlDomain = (url: string): string => { 105 | try { 106 | const short = new URL(url).host; 107 | return short.startsWith('www.') ? short.substring(4) : short; 108 | } catch { 109 | return url; 110 | } 111 | }; 112 | 113 | const useGetUser = (userId: string) => { 114 | const { 115 | data: user, 116 | error, 117 | isLoading, 118 | } = useQuery([`getuser${userId}`], () => 119 | callEndpoint(withParams(ENDPOINT_CONFIGS.getUser, userId)) 120 | ); 121 | return { user, error, isLoading }; 122 | }; 123 | 124 | const useCountComments = (postId: string) => { 125 | const { data: countCommentsRes } = useQuery([`countComments${postId}`], () => 126 | callEndpoint( 127 | withParams(ENDPOINT_CONFIGS.countComments, postId) 128 | ) 129 | ); 130 | return { countCommentsRes }; 131 | }; 132 | -------------------------------------------------------------------------------- /web/src/components/required-input.tsx: -------------------------------------------------------------------------------- 1 | import { Input, InputProps } from '@chakra-ui/react'; 2 | import { useState } from 'react'; 3 | 4 | export const RequiredInput = (props: InputProps) => { 5 | const [touched, setTouched] = useState(false); 6 | 7 | return setTouched(true)} isInvalid={touched && !props.value} {...props} />; 8 | }; 9 | -------------------------------------------------------------------------------- /web/src/contexts/userContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ENDPOINT_CONFIGS, 3 | GetCurrentUserRequest, 4 | GetCurrentUserResponse, 5 | } from '@codersquare/shared'; 6 | import { useQuery } from '@tanstack/react-query'; 7 | import { ReactNode, createContext, useContext } from 'react'; 8 | 9 | import { callEndpoint } from '../fetch'; 10 | import { isLoggedIn } from '../fetch/auth'; 11 | 12 | type UserContext = { 13 | currentUser?: GetCurrentUserResponse; 14 | refreshCurrentUser: () => void; 15 | }; 16 | 17 | type CurrentUserContextProviderProps = { 18 | children: ReactNode; 19 | }; 20 | 21 | export const userContext = createContext({} as UserContext); 22 | export const useCurrentUser = () => useContext(userContext); 23 | 24 | export const CurrentUserContextProvider = ({ 25 | children, 26 | }: CurrentUserContextProviderProps): JSX.Element => { 27 | const { data: currentUser, refetch: refreshCurrentUser } = useQuery( 28 | ['getCurrentUser'], 29 | () => 30 | callEndpoint(ENDPOINT_CONFIGS.getCurrentUser), 31 | { 32 | enabled: isLoggedIn(), 33 | } 34 | ); 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /web/src/doc-title.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export const useDocumentTitle = (title: string) => { 4 | useEffect(() => { 5 | document.title = 'Codersquare | ' + title; 6 | }, [title]); 7 | }; 8 | -------------------------------------------------------------------------------- /web/src/fetch/auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ENDPOINT_CONFIGS, 3 | SignInRequest, 4 | SignInResponse, 5 | SignUpRequest, 6 | SignUpResponse, 7 | } from '@codersquare/shared'; 8 | 9 | import { callEndpoint } from '.'; 10 | 11 | export const LOCAL_STORAGE_JWT = 'jwtToken'; 12 | 13 | export const getLocalStorageJWT = (): string => { 14 | return localStorage.getItem(LOCAL_STORAGE_JWT) || ''; 15 | }; 16 | 17 | export const isLoggedIn = (): boolean => { 18 | const jwt = getLocalStorageJWT(); 19 | return !!jwt; 20 | }; 21 | 22 | export const signIn = async (login: string, password: string) => { 23 | const res = await callEndpoint(ENDPOINT_CONFIGS.signin, { 24 | login, 25 | password, 26 | }); 27 | localStorage.setItem(LOCAL_STORAGE_JWT, res.jwt); 28 | }; 29 | 30 | export const signUp = async ( 31 | firstName: string, 32 | lastName: string, 33 | email: string, 34 | password: string, 35 | userName: string 36 | ) => { 37 | const res = await callEndpoint(ENDPOINT_CONFIGS.signup, { 38 | firstName, 39 | lastName, 40 | email, 41 | password, 42 | userName, 43 | }); 44 | localStorage.setItem(LOCAL_STORAGE_JWT, res.jwt); 45 | }; 46 | 47 | export const signOut = () => { 48 | localStorage.removeItem(LOCAL_STORAGE_JWT); 49 | }; 50 | -------------------------------------------------------------------------------- /web/src/fetch/index.ts: -------------------------------------------------------------------------------- 1 | import { ERRORS, EndpointConfig } from '@codersquare/shared'; 2 | import { QueryClient } from '@tanstack/react-query'; 3 | 4 | import { isDev } from '../util'; 5 | import { getLocalStorageJWT, isLoggedIn, signOut } from './auth'; 6 | 7 | const API_HOST = isDev ? `http://localhost:${window.location.port}` : 'https://api.codersquare.xyz'; 8 | 9 | export class ApiError extends Error { 10 | public status: number; 11 | 12 | constructor(status: number, msg: string) { 13 | super(msg); 14 | this.status = status; 15 | } 16 | } 17 | 18 | export const queryClient = new QueryClient({ 19 | defaultOptions: { 20 | queries: { 21 | networkMode: 'offlineFirst', 22 | retry(failureCount, error) { 23 | const { status } = error as ApiError; 24 | if (typeof status !== 'number') { 25 | console.error('got non-numeric error code:', error); 26 | return true; 27 | } 28 | return status >= 500 && failureCount < 2; 29 | }, 30 | }, 31 | }, 32 | }); 33 | 34 | export async function callEndpoint( 35 | endpoint: EndpointConfig, 36 | request?: Request 37 | ): Promise { 38 | const { url, method, auth } = endpoint; 39 | const requestBody = request ? JSON.stringify(request) : undefined; 40 | const response = await fetch(`${API_HOST}${url}`, { 41 | method: method.toUpperCase(), 42 | headers: { 43 | 'Content-Type': 'application/json', 44 | // We include an Authorization header when it's explicitly required or 45 | // when the user is logged in. 46 | ...((auth || isLoggedIn()) && { Authorization: `Bearer ${getLocalStorageJWT()}` }), 47 | }, 48 | body: requestBody, 49 | }); 50 | if (!response.ok) { 51 | let msg = ''; 52 | try { 53 | msg = (await response.json()).error; 54 | // Sign user out and refresh if the token has expired 55 | if (msg === ERRORS.TOKEN_EXPIRED) { 56 | signOut(); 57 | window.location.reload(); 58 | } 59 | } finally { 60 | throw new ApiError(response.status, msg); 61 | } 62 | } 63 | const isJson = response.headers.get('content-type')?.includes('application/json'); 64 | return isJson ? ((await response.json()) as Response) : ({} as Response); 65 | } 66 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 4 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | 9 | code { 10 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 11 | } 12 | 13 | .chakra-text a { 14 | text-decoration: underline; 15 | text-decoration-color: brown; 16 | color: brown; 17 | } 18 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider } from '@chakra-ui/react'; 2 | import { QueryClientProvider } from '@tanstack/react-query'; 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/client'; 5 | 6 | import { App } from './App'; 7 | import { queryClient } from './fetch'; 8 | import './index.css'; 9 | import { theme } from './theme'; 10 | 11 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 12 | 13 | root.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | -------------------------------------------------------------------------------- /web/src/pages/list-posts.tsx: -------------------------------------------------------------------------------- 1 | import { ENDPOINT_CONFIGS, ListPostsRequest, ListPostsResponse } from '@codersquare/shared'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | 4 | import { PostCard } from '../components/post-card'; 5 | import { useDocumentTitle } from '../doc-title'; 6 | import { callEndpoint } from '../fetch'; 7 | 8 | export const ListPosts = () => { 9 | useDocumentTitle('Home'); 10 | const { data, error, isLoading, refetch } = useQuery(['listposts'], () => 11 | callEndpoint(ENDPOINT_CONFIGS.listPosts) 12 | ); 13 | 14 | if (isLoading) { 15 | return
loading...
; 16 | } 17 | 18 | if (error) { 19 | return
error loading posts
; 20 | } 21 | 22 | return ( 23 | <> 24 | {data?.posts.map((post, i) => ( 25 | 26 | ))} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /web/src/pages/new-post.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, FormControl, FormLabel, Text } from '@chakra-ui/react'; 2 | import { CreatePostRequest, CreatePostResponse, ENDPOINT_CONFIGS } from '@codersquare/shared'; 3 | import { FormEvent, useCallback, useEffect, useState } from 'react'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | import { RequiredInput } from '../components/required-input'; 7 | import { useDocumentTitle } from '../doc-title'; 8 | import { ApiError, callEndpoint } from '../fetch'; 9 | import { isLoggedIn } from '../fetch/auth'; 10 | import { ROUTES } from '../routes'; 11 | 12 | export const NewPost = () => { 13 | useDocumentTitle('New post'); 14 | const navigate = useNavigate(); 15 | const [title, setTitle] = useState(''); 16 | const [url, setUrl] = useState(''); 17 | const [error, setError] = useState(''); 18 | 19 | useEffect(() => { 20 | if (!isLoggedIn()) { 21 | navigate(ROUTES.HOME); 22 | } 23 | }, [navigate]); 24 | 25 | const submitPost = useCallback( 26 | async (e: FormEvent | MouseEvent) => { 27 | e.preventDefault(); 28 | try { 29 | await callEndpoint(ENDPOINT_CONFIGS.createPost, { 30 | title, 31 | url, 32 | }); 33 | navigate(ROUTES.HOME); 34 | } catch (e) { 35 | setError((e as ApiError).message); 36 | } 37 | }, 38 | [navigate, title, url] 39 | ); 40 | 41 | return ( 42 |
43 | 44 | 45 | Add a title 46 | setTitle(e.target.value)} 51 | style={{ unicodeBidi: 'plaintext' }} 52 | /> 53 | 54 | 55 | 56 | Add the post URL 57 | setUrl(e.target.value)} 62 | style={{ unicodeBidi: 'plaintext' }} 63 | /> 64 | 65 | 66 | 67 | 70 | 71 | 72 | {!!error && {error}} 73 | 74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /web/src/pages/sign-in.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Text } from '@chakra-ui/react'; 2 | import { FormEvent, useCallback, useEffect, useState } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { RequiredInput } from '../components/required-input'; 6 | import { useCurrentUser } from '../contexts/userContext'; 7 | import { useDocumentTitle } from '../doc-title'; 8 | import { isLoggedIn, signIn } from '../fetch/auth'; 9 | import { ROUTES } from '../routes'; 10 | 11 | export const SignIn = () => { 12 | useDocumentTitle('Sign in'); 13 | const navigate = useNavigate(); 14 | const [un, setUn] = useState(''); 15 | const [pw, setPw] = useState(''); 16 | const [error, setError] = useState(''); 17 | const { refreshCurrentUser } = useCurrentUser(); 18 | 19 | const signin = useCallback( 20 | async (e: FormEvent | MouseEvent) => { 21 | e.preventDefault(); 22 | try { 23 | await signIn(un, pw); 24 | refreshCurrentUser(); 25 | navigate(ROUTES.HOME); 26 | } catch { 27 | setError('Bad credentials'); 28 | } 29 | }, 30 | [navigate, pw, refreshCurrentUser, un] 31 | ); 32 | 33 | useEffect(() => { 34 | if (isLoggedIn()) { 35 | navigate(ROUTES.HOME); 36 | } 37 | }, [navigate]); 38 | 39 | return ( 40 |
41 | 42 | setUn(e.target.value)} 47 | /> 48 | 49 | setPw(e.target.value)} 55 | /> 56 | 57 | 58 | 61 | 62 | 63 | {!!error && {error}} 64 | 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /web/src/pages/sign-up.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Input, Text } from '@chakra-ui/react'; 2 | import { FormEvent, useCallback, useEffect, useState } from 'react'; 3 | import { useNavigate } from 'react-router-dom'; 4 | 5 | import { RequiredInput } from '../components/required-input'; 6 | import { useCurrentUser } from '../contexts/userContext'; 7 | import { useDocumentTitle } from '../doc-title'; 8 | import { ApiError } from '../fetch'; 9 | import { isLoggedIn, signUp } from '../fetch/auth'; 10 | import { ROUTES } from '../routes'; 11 | 12 | export const SignUp = () => { 13 | useDocumentTitle('Sign up'); 14 | const navigate = useNavigate(); 15 | const [fname, setFname] = useState(''); 16 | const [lname, setLname] = useState(''); 17 | const [un, setUn] = useState(''); 18 | const [pw, setPw] = useState(''); 19 | const [email, setEmail] = useState(''); 20 | const [error, setError] = useState(''); 21 | const { refreshCurrentUser } = useCurrentUser(); 22 | 23 | const signup = useCallback( 24 | async (e: FormEvent | MouseEvent) => { 25 | e.preventDefault(); 26 | try { 27 | await signUp(fname, lname, email, pw, un); 28 | refreshCurrentUser(); 29 | navigate(ROUTES.HOME); 30 | } catch (e) { 31 | setError((e as ApiError).message); 32 | } 33 | }, 34 | [fname, lname, email, pw, un, refreshCurrentUser, navigate] 35 | ); 36 | 37 | useEffect(() => { 38 | if (isLoggedIn()) { 39 | navigate(ROUTES.HOME); 40 | } 41 | }, [navigate]); 42 | 43 | return ( 44 |
45 | 46 | setUn(e.target.value)} 51 | /> 52 | 53 | setPw(e.target.value)} 59 | /> 60 | 61 | setEmail(e.target.value)} 66 | /> 67 | 68 | setFname(e.target.value)} 73 | /> 74 | 75 | setLname(e.target.value)} 80 | /> 81 | 82 | 83 | 86 | 87 | 88 | {!!error && {error}} 89 | 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /web/src/pages/user-profile.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Input, InputGroup, InputLeftAddon, Text } from '@chakra-ui/react'; 2 | import { 3 | ENDPOINT_CONFIGS, 4 | GetUserRequest, 5 | GetUserResponse, 6 | UpdateCurrentUserRequest, 7 | UpdateCurrentUserResponse, 8 | withParams, 9 | } from '@codersquare/shared'; 10 | import { useQuery } from '@tanstack/react-query'; 11 | import { FormEvent, useCallback, useState } from 'react'; 12 | import { useParams } from 'react-router-dom'; 13 | 14 | import { useCurrentUser } from '../contexts/userContext'; 15 | import { ApiError, callEndpoint } from '../fetch'; 16 | 17 | enum UserProfileMode { 18 | EDITING, 19 | VIEWING, 20 | } 21 | 22 | export const UserProfile = () => { 23 | const { id } = useParams(); 24 | const [userName, setUserName] = useState(); 25 | const [firstName, setFirstName] = useState(); 26 | const [lastName, setLastName] = useState(); 27 | const [userProfileMode, setUserProfileMode] = useState(UserProfileMode.VIEWING); 28 | const [userUpdateError, setUserUpdateError] = useState(); 29 | const { currentUser, refreshCurrentUser } = useCurrentUser(); 30 | const isOwnProfile = id === currentUser?.id; 31 | const isEditingMode = userProfileMode === UserProfileMode.EDITING; 32 | 33 | // load user profile 34 | const { error, isLoading } = useQuery( 35 | [`getuser${id}`], 36 | () => callEndpoint(withParams(ENDPOINT_CONFIGS.getUser, id!)), 37 | { 38 | onSuccess: data => { 39 | setUserName(data.userName); 40 | setFirstName(data.firstName); 41 | setLastName(data.lastName); 42 | }, 43 | } 44 | ); 45 | 46 | const updateCurrentUser = useCallback(async () => { 47 | await callEndpoint( 48 | ENDPOINT_CONFIGS.updateCurrentUser, 49 | { 50 | userName, 51 | firstName, 52 | lastName, 53 | } 54 | ); 55 | }, [userName, firstName, lastName]); 56 | 57 | const handleSaveClick = async () => { 58 | try { 59 | await updateCurrentUser(); 60 | setUserUpdateError(''); 61 | refreshCurrentUser(); 62 | setUserProfileMode(UserProfileMode.VIEWING); 63 | } catch (e) { 64 | setUserUpdateError((e as ApiError).message); 65 | } 66 | }; 67 | 68 | const handleEditClick = () => { 69 | setUserProfileMode(UserProfileMode.EDITING); 70 | }; 71 | 72 | const onEditOrSaveClick = async (e: FormEvent | MouseEvent) => { 73 | e.preventDefault(); 74 | if (userProfileMode === UserProfileMode.EDITING) { 75 | await handleSaveClick(); 76 | } else if (userProfileMode === UserProfileMode.VIEWING) { 77 | handleEditClick(); 78 | } 79 | }; 80 | 81 | if (isLoading) { 82 | return
loading...
; 83 | } 84 | 85 | if (error) { 86 | return
error loading user: {JSON.stringify(error)}
; 87 | } 88 | 89 | return ( 90 | 91 | 92 | 93 | setUserName(e.target.value)} 98 | /> 99 | 100 | 101 | 102 | setFirstName(e.target.value)} 107 | /> 108 | 109 | 110 | 111 | setLastName(e.target.value)} 116 | /> 117 | 118 | {isOwnProfile && ( 119 | 128 | )} 129 | 130 | {!!userUpdateError && {userUpdateError}} 131 | 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /web/src/pages/view-post.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Flex, Text, Textarea } from '@chakra-ui/react'; 2 | import { 3 | CreateCommentRequest, 4 | CreateCommentResponse, 5 | ENDPOINT_CONFIGS, 6 | GetPostRequest, 7 | GetPostResponse, 8 | ListCommentsResponse, 9 | withParams, 10 | } from '@codersquare/shared'; 11 | import { useQuery } from '@tanstack/react-query'; 12 | import { useCallback, useState } from 'react'; 13 | import { useParams } from 'react-router-dom'; 14 | 15 | import { CommentCard } from '../components/comment-card'; 16 | import { PostCard } from '../components/post-card'; 17 | import { useDocumentTitle } from '../doc-title'; 18 | import { callEndpoint } from '../fetch'; 19 | import { isLoggedIn } from '../fetch/auth'; 20 | 21 | export const ViewPost = () => { 22 | const { id: postId } = useParams(); 23 | const { 24 | data, 25 | error, 26 | isLoading, 27 | refetch: refetchPost, 28 | } = useQuery(['viewpost'], () => 29 | callEndpoint(withParams(ENDPOINT_CONFIGS.getPost, postId!)) 30 | ); 31 | const { 32 | data: commentsData, 33 | error: commentsError, 34 | isLoading: commentsLoading, 35 | refetch: refetchComments, 36 | } = useQuery(['listcomments'], () => 37 | callEndpoint<{}, ListCommentsResponse>(withParams(ENDPOINT_CONFIGS.listComments, postId!)) 38 | ); 39 | const [comment, setComment] = useState(''); 40 | const submitComment = useCallback(async () => { 41 | await callEndpoint( 42 | withParams(ENDPOINT_CONFIGS.createComment, postId!), 43 | { comment } 44 | ); 45 | setComment(''); 46 | refetchComments(); 47 | }, [comment, postId, refetchComments]); 48 | 49 | const postname = isLoading ? 'Loading..' : error || !data ? 'Error' : data.post.title; 50 | useDocumentTitle(postname); 51 | 52 | if (isLoading) { 53 | return
loading...
; 54 | } 55 | 56 | if (error || !data?.post) { 57 | return
error loading post: {JSON.stringify(error)}
; 58 | } 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 |
66 |
67 | 68 |
69 | 70 | {commentsError ? Error loading comments. : null} 71 | 72 | {commentsLoading ? Loading comments... : null} 73 | 74 | {isLoggedIn() && ( 75 | <> 76 |