Compare commits

..

27 Commits
CR ... main

Author SHA1 Message Date
Pau Costa Ferrer 1fe72cd4f5 💄 UI adjustment
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-19 20:00:58 +01:00
Pau Costa Ferrer 2394c63977
Update README.md
Repository url was wrong...
2024-02-12 14:02:45 +01:00
Pau Costa Ferrer 96717643c5 🐛 Deleting a post was not updating the global feed
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 13:38:05 +01:00
Pau Costa Ferrer 71781a43c8 📝 Added Readme
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 13:31:52 +01:00
Pau Costa Ferrer 6fbfe81918 💄 Fixed the position of the appbars, and fixed some minor UI bugs
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 12:19:36 +01:00
Pau Costa Ferrer a5e6a9505b 🐛 PostListItems were not updating successfully when liked or edited
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 12:04:21 +01:00
Pau Costa Ferrer 2d9042235c ❇️ Users can now edit posts
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 12:03:31 +01:00
Pau Costa Ferrer a4ba28eaef :feature: Notifications
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 12:02:47 +01:00
Pau Costa Ferrer fc79245f9a :feature: User settings, and private profile
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-12 12:02:14 +01:00
Pau Costa Ferrer 6142dd7000 :feature: New post view
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-11 17:36:20 +01:00
Pau Costa Ferrer 40a978c546 💩 TypeORM was failing to load associated classes methods
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-11 17:36:02 +01:00
Pau Costa Ferrer ca4fb32413 A Post and comments view
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-11 16:15:43 +01:00
Pau Costa Ferrer d73bd1dd04 User profile view
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-11 13:47:37 +01:00
Pau Costa Ferrer e6f73932fa Added search view
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-10 19:11:01 +01:00
Pau Costa Ferrer fda05c661a 🗃️ Private user setup, and preparations for images upload
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-10 15:24:47 +01:00
Pau Costa Ferrer be57bd32b8 🎨 Removed unused code and imports
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-10 14:43:33 +01:00
Pau Costa Ferrer 4fdb17733d Implemented post list view
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-10 14:43:33 +01:00
Pau Costa Ferrer 0cdcc915f4 🩹 The API was missing some ease of use parameters
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-10 14:43:33 +01:00
Pau Costa Ferrer e27423a2e5 🩹 The API was missing some ease of use parameters
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-09 13:45:20 +01:00
Pau Costa Ferrer 591dae9567 🏗️ Views and routes boilerplate
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-08 16:56:47 +01:00
Pau Costa Ferrer 31ba7bead1 💄 Initial UI Root layout
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-08 13:27:01 +01:00
Pau Costa Ferrer c71bac5bd2 Auth views
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-07 17:39:32 +01:00
Pau Costa Ferrer d2f42d397f 🚧 Changed api generation tool
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-07 17:38:59 +01:00
Pau Costa Ferrer ec01ee98b2 🚧 Client Setup and api docs
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-07 11:09:25 +01:00
Pau Costa Ferrer e5a65086be 📝 OpenApi docs and endpoint for the BackEnd Routes
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-04 20:33:26 +01:00
Pau Costa Ferrer c09b56b0cd Comments, likes, and notifications
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-04 12:30:11 +01:00
Pau Costa Ferrer 28e4b5c318 User listing, getting, following and unfollowing
Signed-off-by: Pau Costa <mico@micodev.es>
2024-02-03 14:44:44 +01:00
65 changed files with 8084 additions and 152 deletions

897
DevSpaceOApi.json Normal file
View File

@ -0,0 +1,897 @@
{
"openapi": "3.0.0",
"info": {
"title": "DevSpace API",
"description": "API for DevSpace",
"version": "0.1.0"
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"schemas": {
"Post": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Post ID"
},
"title": {
"type": "string",
"description": "Post title"
},
"content": {
"type": "string",
"description": "Post content"
},
"createdAt": {
"type": "string",
"description": "Post creation date"
},
"createdBy": {
"$ref": "#/components/schemas/User"
},
"likedBy": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
},
"comments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Comment"
}
}
}
},
"Comment": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Comment ID"
},
"content": {
"type": "string",
"description": "Comment content"
},
"createdAt": {
"type": "string",
"description": "Comment creation date"
},
"createdBy": {
"$ref": "#/components/schemas/User"
},
"likedBy": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
},
"Notification": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Notification ID"
},
"message": {
"type": "string",
"description": "Notification message"
},
"timeStamp": {
"type": "string",
"description": "Notification creation date"
},
"seen": {
"type": "boolean",
"description": "Notification seen status"
}
}
},
"User": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "User ID"
},
"firstName": {
"type": "string",
"description": "User first name"
},
"isPrivate": {
"type": "boolean",
"description": "User private status"
},
"profilePictureId": {
"type": "string",
"description": "User profile picture ID"
},
"lastName": {
"type": "string",
"description": "User last name"
}
}
},
"UserWithRelations": {
"allOf": [
{
"$ref": "#/components/schemas/User"
},
{
"type": "object",
"properties": {
"posts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Post"
}
},
"comments": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Comment"
}
},
"followed": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
},
"followers": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
}
]
},
"UserWithRelationsAndNotifications": {
"allOf": [
{
"$ref": "#/components/schemas/UserWithRelations"
},
{
"type": "object",
"properties": {
"notifications": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Notification"
}
}
}
}
]
}
}
},
"paths": {
"/auth/signup": {
"post": {
"tags": ["Authentication"],
"summary": "Sign up a new user",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The email of the user"
},
"password": {
"type": "string",
"description": "The password of the user"
},
"passwordValidation": {
"type": "string",
"description": "Password validation field"
},
"firstName": {
"type": "string",
"description": "The first name of the user"
},
"lastName": {
"type": "string",
"description": "The last name of the user"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successfully signed up the user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
},
"400": {
"description": "Invalid input or user already exists"
}
}
}
},
"/auth/login": {
"post": {
"tags": ["Authentication"],
"summary": "Log in a user",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "The email of the user"
},
"password": {
"type": "string",
"description": "The password of the user"
},
"longExpiration": {
"type": "boolean",
"description": "Whether to keep the user logged in for a long time"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successfully logged in the user",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
},
"token": {
"type": "string",
"description": "The JWT token for the user"
}
}
}
}
}
},
"400": {
"description": "Missing email or password"
},
"401": {
"description": "Incorrect email or password"
}
}
}
},
"/auth/logout": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Authentication"],
"summary": "Log out the user",
"responses": {
"200": {
"description": "Successfully logged out the user",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success"
}
}
}
}
}
}
}
}
},
"/posts": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "getAllPosts",
"summary": "Get all posts",
"responses": {
"200": {
"description": "A list of all posts",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Post"
}
}
}
}
}
}
},
"post": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "createPost",
"summary": "Create a post",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Post title"
},
"content": {
"type": "string",
"description": "Post content"
}
}
}
}
}
},
"responses": {
"201": {
"description": "A post",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
}
},
"400": {
"description": "Title and content are required"
}
}
}
},
"/posts/{id}": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "getPost",
"summary": "Get a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "A post",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
}
},
"400": {
"description": "Invalid ID"
},
"404": {
"description": "No post found with that ID"
}
}
},
"patch": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "updatePost",
"summary": "Update a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Post title"
},
"content": {
"type": "string",
"description": "Post content"
}
}
}
}
}
},
"responses": {
"200": {
"description": "A post",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
}
},
"400": {
"description": "Invalid ID or Title and content are required"
},
"404": {
"description": "No post found with that ID"
}
}
},
"delete": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "deletePost",
"summary": "Delete a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"responses": {
"204": {
"description": "No content"
},
"400": {
"description": "Invalid ID"
},
"404": {
"description": "No post found with that ID"
}
}
}
},
"/posts/followed": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "getFollowedPosts",
"summary": "Get posts of followed users",
"responses": {
"200": {
"description": "A list of posts",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Post"
}
}
}
}
}
}
}
},
"/posts/{id}/like": {
"post": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "likePost",
"summary": "Like a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "A post",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
}
},
"400": {
"description": "Invalid ID or You have already liked this post"
},
"404": {
"description": "No post found with that ID"
}
}
},
"delete": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "unlikePost",
"summary": "Unlike a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "A post",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Post"
}
}
}
},
"400": {
"description": "Invalid ID or You have not liked this post"
},
"404": {
"description": "No post found with that ID"
}
}
}
},
"/posts/{id}/comment": {
"post": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Posts"],
"operationId": "commentPost",
"summary": "Comment on a post by ID",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the post",
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "Comment content"
}
}
}
}
}
},
"responses": {
"201": {
"description": "A comment",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Comment"
}
}
}
},
"400": {
"description": "Invalid Request"
},
"404": {
"description": "No post found with that ID"
}
}
}
},
"/users": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Users"],
"summary": "Get all users",
"responses": {
"200": {
"description": "A list of all users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
}
}
},
"/users/{id}": {
"get": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Users"],
"summary": "Get a single user",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"description": "ID of the user",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "A single user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserWithRelations"
}
}
}
}
}
}
},
"/users/me": {
"get": {
"tags": ["Users"],
"summary": "Get the currently logged in user",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "The currently logged in user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserWithRelationsAndNotifications"
}
}
}
}
}
},
"patch": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Users"],
"summary": "Update the currently logged in user",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"isPrivate": {
"type": "boolean",
"description": "Whether the user's account is private"
},
"profilePictureId": {
"type": "string",
"description": "The ID of the user's profile picture"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Successfully updated the user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserWithRelationsAndNotifications"
}
}
}
},
"400": {
"description": "Invalid request body"
}
}
}
},
"/users/follow/{id}": {
"post": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Users"],
"summary": "Follow a user",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer",
"description": "The ID of the user to follow"
}
}
],
"responses": {
"200": {
"description": "Successfully followed the user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserWithRelations"
}
}
}
},
"400": {
"description": "Invalid ID"
},
"404": {
"description": "No user found with that ID"
}
}
},
"delete": {
"security": [
{
"bearerAuth": []
}
],
"tags": ["Users"],
"summary": "Unfollow a user",
"parameters": [
{
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer",
"description": "The ID of the user to unfollow"
}
}
],
"responses": {
"200": {
"description": "Successfully unfollowed the user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserWithRelations"
}
}
}
},
"400": {
"description": "Invalid ID"
},
"404": {
"description": "No user found with that ID"
}
}
}
}
},
"tags": []
}

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# DevSpace
DevSpace is a social platform demo made with Express, Typescript, React, Redux, MaterialUI and Node. You can check it out at www.devspace.micosita.es, and there is an api spec at www.api.devspace.micosita/docs
### Disclaimer
This app was made over the course of a week and a half of work. It is by no means production ready, and many things need to be changed for it to be scalable to a sizable userbase. It is fun toy to play with, or to implement new features.
## Features
- User authentication
- Create, read, update, and delete posts
- User search
- Follow other users
- Notifications
- Private or public profiles
- Responsive design
- Liking and commenting posts
## Getting Started
These instructions will get you a copy of the project up and running on your local machine for development and testing purposes.
### Prerequisites
What things you need to install the software and how to install them:
- Node.js
- npm
- docker
### Installation
1. Clone the repo
```sh
git clone https://github.com/micosilent/devspace.git
```
2. Set the docker-compose.yml to match your environment, pay special attention at the secrets, and routes set in the environment variables.
3. Run the docker stack
```sh
docker compose up -d
```
We are done!
## Contributing
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
1. Fork the Project
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the Branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
### TODO:
- [ ] Implement image uploads (using multer and multipart forms)
- [ ] Real time chat feature (using websockets)
- [ ] Implement a proper paging middleware in the backend for typeORM
- [ ] Refreshing the page causes requests to be sent without JWT
## License
Distributed under the MIT License. See `LICENSE` for more information.

7
client/openapitools.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.3.0"
}
}

808
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,18 +3,32 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fontsource/roboto": "^5.0.8",
"@mui/icons-material": "^5.15.8",
"@mui/material": "^5.15.7",
"@reduxjs/toolkit": "^2.1.0",
"axios": "^1.6.7",
"match-sorter": "^6.3.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.0",
"react-scripts": "5.0.1",
"sort-by": "^1.2.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/axios": "^0.14.0",
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.77", "@types/node": "^16.18.77",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,26 +0,0 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;

4
client/src/api/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View File

@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@ -0,0 +1 @@
7.3.0

1867
client/src/api/api.ts Normal file

File diff suppressed because it is too large Load Diff

86
client/src/api/base.ts Normal file
View File

@ -0,0 +1,86 @@
/* tslint:disable */
/* eslint-disable */
/**
* DevSpace API
* API for DevSpace
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: RawAxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath ?? basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {
}

150
client/src/api/common.ts Normal file
View File

@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* DevSpace API
* API for DevSpace
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (axios.defaults.baseURL ? '' : configuration?.basePath ?? basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View File

@ -0,0 +1,110 @@
/* tslint:disable */
/* eslint-disable */
/**
* DevSpace API
* API for DevSpace
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View File

@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

18
client/src/api/index.ts Normal file
View File

@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* DevSpace API
* API for DevSpace
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

View File

@ -0,0 +1,19 @@
import { ThemeProvider } from "@emotion/react";
import React from "react";
import { useSelector } from "react-redux";
import { selectDarkMode } from "./loginSlice";
import { createTheme } from "@mui/material";
const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
const darkThemeEnabled = useSelector(selectDarkMode);
const defaultTheme = createTheme({
palette: {
mode: darkThemeEnabled ? "dark" : "light",
},
});
return <ThemeProvider theme={defaultTheme}>{children}</ThemeProvider>;
};
export default AppThemeProvider;

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
function getWindowDimensions() {
const { innerWidth: width, innerHeight: height } = window;
return { width, height };
}
export default function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState(
getWindowDimensions()
);
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions());
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return windowDimensions;
}

View File

@ -0,0 +1,181 @@
import { createSlice } from "@reduxjs/toolkit";
import { Status } from "../util/types";
import {
AuthLoginPostRequest,
AuthSignupPostRequest,
AuthenticationApi,
Configuration,
UsersApi,
} from "../api";
import { AppThunk } from "./store";
interface loginState {
loggedIn: boolean;
status: Status;
error: string | null;
darkMode: boolean;
userInfo: {
firstName: string;
lastName: string;
jwt: string;
id: number;
profilePictureId: string;
notifications: Notification[];
isPrivate: boolean;
};
}
const storageDarkMode = localStorage.getItem("darkMode") === "true";
const initialState: loginState = {
loggedIn: false,
status: Status.idle,
darkMode: storageDarkMode || false,
error: null,
userInfo: {
isPrivate: false,
firstName: "",
lastName: "",
jwt: "",
id: -1,
profilePictureId: "",
notifications: [],
},
};
export const loginSlice = createSlice({
name: "login",
initialState,
reducers: {
login: (state, action) => {
state.loggedIn = true;
state.userInfo.id = action.payload.id;
state.userInfo.jwt = action.payload.jwt;
state.userInfo.firstName = action.payload.firstName;
state.userInfo.lastName = action.payload.lastName;
state.userInfo.profilePictureId = action.payload.profilePictureId;
state.userInfo.notifications = action.payload.notifications;
state.userInfo.isPrivate = action.payload.isPrivate;
},
logoff: (state) => {
state.loggedIn = false;
state.userInfo = initialState.userInfo;
},
setStatus: (state, action) => {
state.status = action.payload;
},
setError: (state, action) => {
state.error = action.payload;
},
setDarkMode: (state, action) => {
state.darkMode = action.payload;
},
setPrivate: (state, action) => {
state.userInfo.isPrivate = action.payload;
},
},
});
const authApi = new AuthenticationApi(
new Configuration({
basePath: process.env.REACT_APP_BACKEND_URL,
})
);
export const postLogin =
(params: AuthLoginPostRequest): AppThunk =>
async (dispatch) => {
let response, userResponse;
try {
dispatch(setStatus(Status.loading));
response = await authApi.authLoginPost(params);
await addJWT(response.data.token || "");
// Get user info
const userApi = new UsersApi(
new Configuration({
basePath: process.env.REACT_APP_BACKEND_URL,
accessToken: response.data.token,
})
);
userResponse = await userApi.usersMeGet();
dispatch(
login({
jwt: response.data.token,
firstName: userResponse.data.firstName,
lastName: userResponse.data.lastName,
id: userResponse.data.id,
profilePictureId: userResponse.data.profilePictureId,
notifications: userResponse.data.notifications,
})
);
dispatch(setStatus(Status.succeeded));
} catch (error) {
dispatch(setStatus(Status.failed));
const errorMessage = "Invalid email or password";
dispatch(setError(errorMessage));
}
};
export const postSignup =
(params: AuthSignupPostRequest): AppThunk =>
async (dispatch) => {
let response;
console.log(params);
try {
dispatch(setStatus(Status.loading));
response = await authApi.authSignupPost(params);
dispatch(postLogin({ email: params.email, password: params.password }));
} catch (error) {
dispatch(setStatus(Status.failed));
const errorMessage = "Change this pls";
dispatch(setError(errorMessage));
}
};
export const updateMe =
(isPrivate: boolean): AppThunk =>
async (dispatch) => {
const userApi = new UsersApi(
new Configuration({
basePath: process.env.REACT_APP_BACKEND_URL,
accessToken: localStorage.getItem("jwt") || "",
})
);
console.log("From inside updateME dispatch, param is:", isPrivate);
await userApi.usersMePatch({ isPrivate });
const userResponse = await userApi.usersMeGet();
dispatch(setPrivate(userResponse.data.isPrivate));
};
export const postLogout = (): AppThunk => async (dispatch) => {
localStorage.removeItem("jwt");
sessionStorage.removeItem("jwt");
dispatch(logoff());
await authApi.authLogoutGet();
};
const addJWT = async (token: string) => {
localStorage.setItem("jwt", token);
};
export const { login, logoff, setStatus, setError, setDarkMode, setPrivate } =
loginSlice.actions;
export default loginSlice.reducer;
export const selectLoggedIn = (state: { login: loginState }) =>
state.login.loggedIn;
export const selectUserInfo = (state: { login: loginState }) =>
state.login.userInfo;
export const selectErrorMessage = (state: { login: loginState }) =>
state.login.error;
export const selectDarkMode = (state: { login: loginState }) =>
state.login.darkMode;

168
client/src/app/postSlice.ts Normal file
View File

@ -0,0 +1,168 @@
import { Store, createSlice } from "@reduxjs/toolkit";
import { Configuration, Post, PostsApi } from "../api";
import { Status } from "../util/types";
import { AppThunk, store } from "./store";
interface postSliceInterface {
status: Status;
followedPosts: Post[];
globalPosts: Post[];
aPost: Post | null;
}
const initialState: postSliceInterface = {
status: Status.idle,
followedPosts: [],
globalPosts: [],
aPost: null,
};
export const postSlice = createSlice({
name: "post",
initialState,
reducers: {
setStatus: (state, action) => {
state.status = action.payload;
},
setFollowedPosts: (state, action) => {
state.followedPosts = action.payload;
},
setGlobalPosts: (state, action) => {
state.globalPosts = action.payload;
},
setAPost: (state, action) => {
state.aPost = action.payload;
},
},
});
export const fetchFollowedPosts = (): AppThunk => async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
const response = await postApi.getFollowedPosts();
dispatch(setFollowedPosts(response.data));
dispatch(setStatus(Status.idle));
};
export const fetchGlobalPosts = (): AppThunk => async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
const response = await postApi.getAllPosts();
dispatch(setGlobalPosts(response.data));
dispatch(setStatus(Status.idle));
};
export const likePost =
(postId: number, postType?: "global" | "followed"): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.likePost(postId);
dispatch(setStatus(Status.idle));
if (postType === "global") {
dispatch(fetchGlobalPosts());
} else if (postType === "followed") {
dispatch(fetchFollowedPosts());
} else {
dispatch(fetchFollowedPosts());
dispatch(fetchGlobalPosts());
}
};
export const unLikePost =
(postId: number, postType?: "global" | "followed"): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.unlikePost(postId);
dispatch(setStatus(Status.idle));
if (postType === "global") {
dispatch(fetchGlobalPosts());
} else if (postType === "followed") {
dispatch(fetchFollowedPosts());
} else {
dispatch(fetchFollowedPosts());
dispatch(fetchGlobalPosts());
}
};
export const fetchAPost =
(postId: number): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
const response = await postApi.getPost(postId);
dispatch(setAPost(response.data));
dispatch(setStatus(Status.idle));
};
export const commentAPost =
(postId: number, comment: string): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.commentPost(postId, { content: comment });
dispatch(fetchAPost(postId));
dispatch(setStatus(Status.idle));
};
export const createPost =
(title: string, content: string): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.createPost({ title, content });
dispatch(fetchGlobalPosts());
dispatch(setStatus(Status.idle));
};
export const updatePost =
(postId: number, title: string, content: string): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.updatePost(postId, { title, content });
dispatch(fetchAPost(postId));
dispatch(fetchGlobalPosts());
dispatch(setStatus(Status.idle));
};
export const deletePost =
(postId: number): AppThunk =>
async (dispatch: any) => {
const postApi = createApi(store);
dispatch(setStatus(Status.loading));
await postApi.deletePost(postId);
dispatch(fetchGlobalPosts());
dispatch(setStatus(Status.idle));
};
export const { setFollowedPosts, setGlobalPosts, setStatus, setAPost } =
postSlice.actions;
export default postSlice.reducer;
export const selectFollowedPosts = (state: any) => state.post.followedPosts;
export const selectAllPosts = (state: any) => state.post.globalPosts;
export const selectStatus = (state: any) => state.post.status;
export const selectAPost = (state: any) => state.post.aPost;
function createApi(store: Store) {
return new PostsApi(
new Configuration({
basePath: process.env.REACT_APP_BACKEND_URL,
accessToken: store.getState().login.userInfo.jwt,
})
);
}

25
client/src/app/store.ts Normal file
View File

@ -0,0 +1,25 @@
import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import loginReducer from "./loginSlice";
import postReducer from "./postSlice";
import usersReducer from "./usersSlice";
export const store = configureStore({
reducer: {
login: loginReducer,
post: postReducer,
users: usersReducer,
},
devTools: true,
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
export const useAppDispatch: () => AppDispatch = useDispatch;

View File

@ -0,0 +1,120 @@
import { Store, createSlice } from "@reduxjs/toolkit";
import { Configuration, User, UserWithRelations, UsersApi } from "../api";
import { Status } from "../util/types";
import { AppThunk, store } from "./store";
interface userState {
status: Status;
error: string | null;
users: User[];
userWithRelations: UserWithRelations | null;
}
const initialState: userState = {
status: Status.idle,
error: null,
users: [],
userWithRelations: null,
};
export const usersSlice = createSlice({
name: "users",
initialState,
reducers: {
setUsers: (state, action) => {
state.users = action.payload;
},
setStatus: (state, action) => {
state.status = action.payload;
},
setError: (state, action) => {
state.error = action.payload;
},
setUserWithRelations: (state, action) => {
state.userWithRelations = action.payload;
},
},
});
export const getUsers = (): AppThunk => async (dispatch) => {
const api = createApi(store);
dispatch(setStatus(Status.loading));
try {
const response = await api.usersGet();
dispatch(setUsers(response.data));
dispatch(setStatus(Status.idle));
} catch (error) {
dispatch(setError((error as Error).message));
dispatch(setStatus(Status.idle));
}
};
export const getUserWithRelations =
(userId: number): AppThunk =>
async (dispatch) => {
const api = createApi(store);
dispatch(setStatus(Status.loading));
try {
const response = await api.usersIdGet(userId);
dispatch(setUserWithRelations(response.data));
dispatch(setStatus(Status.idle));
} catch (error) {
dispatch(setError((error as Error).message));
dispatch(setStatus(Status.idle));
}
};
export const followUser =
(userId: number): AppThunk =>
async (dispatch) => {
const api = createApi(store);
dispatch(setStatus(Status.loading));
try {
console.log("Trying to follow: ", userId);
await api.usersFollowIdPost(userId);
} catch (error) {
dispatch(setError((error as Error).message));
} finally {
dispatch(getUserWithRelations(userId));
dispatch(setStatus(Status.idle));
}
};
export const unFollowUser =
(userId: number): AppThunk =>
async (dispatch) => {
const api = createApi(store);
dispatch(setStatus(Status.loading));
try {
console.log("Trying to unfollow: ", userId);
await api.usersFollowIdDelete(userId);
} catch (error) {
dispatch(setError((error as Error).message));
} finally {
dispatch(getUserWithRelations(userId));
dispatch(setStatus(Status.idle));
}
};
export const { setUsers, setStatus, setError, setUserWithRelations } =
usersSlice.actions;
export default usersSlice.reducer;
export const selectUsers = (state: { users: userState }) => state.users.users;
export const selectAUser = (state: { users: userState }) =>
state.users.userWithRelations;
export const selectStatus = (state: { users: userState }) => state.users.status;
export const selectError = (state: { users: userState }) => state.users.error;
function createApi(store: Store) {
const configuration = new Configuration({
basePath: process.env.REACT_APP_BACKEND_URL,
accessToken: store.getState().login.userInfo.jwt,
});
return new UsersApi(configuration);
}

View File

@ -0,0 +1,18 @@
import { Link, Typography } from "@mui/material";
export function Copyright(props: any) {
return (
<Typography
variant="body2"
color="text.secondary"
align="center"
{...props}
>
{"Copyright © "}
<Link color="inherit" href="https://tutrastero.com/">
Tu Trastero Tu Otro Espacio S.L
</Link>{" "}
{new Date().getFullYear()}.
</Typography>
);
}

View File

@ -0,0 +1,45 @@
import {Grid, Hidden, Typography, Drawer, Box} from "@mui/material";
export default function ResponsiveDrawer(){
const drawer = (
<div>
<Grid container direction="column">
<Grid item>
<Typography variant="h6">Home</Typography>
</Grid>
<Grid item>
<Typography variant="h6">Search</Typography>
</Grid>
<Grid item>
<Typography variant="h6">Notifications</Typography>
</Grid>
</Grid>
</div>
);
return (
<Box sx={{ display: 'flex' }}>
{/* The implementation can be swapped with js to avoid SEO duplication of links. */}
<Hidden smUp implementation="css">
<Drawer
variant="temporary"
anchor={'bottom'}
open={true}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
>
{drawer}
</Drawer>
</Hidden>
<Hidden xsDown implementation="css">
<Drawer
variant="permanent"
open
>
{drawer}
</Drawer>
</Hidden>
</Box>
);
}

View File

@ -0,0 +1,5 @@
import { Divider, styled } from "@mui/material";
export const StyledDivider = styled(Divider)({
marginBottom: "2%",
});

View File

@ -0,0 +1,20 @@
import { Avatar } from "@mui/material";
import { User, UserWithRelations } from "../api";
interface AppAvatarProps {
user: User | UserWithRelations;
small?: boolean;
}
export function AppAvatar(props: AppAvatarProps) {
return (
<Avatar
alt={`${props.user.firstName} ${props.user.lastName}`}
src={`/images/${props.user.profilePictureId}`}
sx={{
width: props.small ? "24px" : "42px",
height: props.small ? "24px" : "42px",
}}
/>
);
}

View File

@ -0,0 +1,60 @@
import { BottomNavigation, BottomNavigationAction, Paper } from "@mui/material";
import { useEffect, useState } from "react";
import FeedIcon from "@mui/icons-material/Feed";
import GlobalIcon from "@mui/icons-material/Public";
import AddIcon from "@mui/icons-material/Add";
import SearchIcon from "@mui/icons-material/Search";
import { useLocation, useNavigate } from "react-router-dom";
export default function BottomAppBar() {
const [value, setValue] = useState("");
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
setValue(location.pathname.replace("/", ""));
}, [location]);
const handleClick = (to: string) => () => {
navigate(to);
};
return (
<Paper
sx={{ position: "fixed", bottom: 0, left: 0, right: 0 }}
elevation={8}
>
<BottomNavigation
showLabels
value={value}
onChange={(event, newValue) => {
setValue(newValue);
}}
>
<BottomNavigationAction
label="My Feed"
value="feed"
icon={<FeedIcon />}
onClick={handleClick("feed")}
/>
<BottomNavigationAction
label="Global Feed"
value="global"
icon={<GlobalIcon />}
onClick={handleClick("global")}
/>
<BottomNavigationAction
label="New Post"
value="newpost"
icon={<AddIcon />}
onClick={handleClick("newpost")}
/>
<BottomNavigationAction
label="Search"
value="search"
icon={<SearchIcon />}
onClick={handleClick("search")}
/>
</BottomNavigation>
</Paper>
);
}

View File

@ -0,0 +1,17 @@
import NotificationsIcon from "@mui/icons-material/Notifications";
import { Badge, Tooltip } from "@mui/material";
import { useNavigate } from "react-router-dom";
export default function NotificationBell() {
const navigate = useNavigate();
const handleClick = () => {
navigate("/notifications");
};
return (
<Badge badgeContent={0} color="secondary">
<Tooltip title="Notifications">
<NotificationsIcon onClick={handleClick} />
</Tooltip>
</Badge>
);
}

View File

@ -0,0 +1,142 @@
import {
Button,
Card,
CardActionArea,
CardContent,
Grid,
ListItem,
styled,
Typography,
} from "@mui/material";
import { Post } from "../api";
import MessageIcon from "@mui/icons-material/Message";
import ThumbUpOffAltIcon from "@mui/icons-material/ThumbUpOffAlt";
import ThumbUpAltIcon from "@mui/icons-material/ThumbUpAlt";
import { useNavigate } from "react-router-dom";
import { likePost, unLikePost } from "../app/postSlice";
import { useAppDispatch } from "../app/store";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { selectUserInfo } from "../app/loginSlice";
interface PostListItemProps {
post: Post;
postType: "feed" | "user";
feedType?: "global" | "followed";
}
export default function PostListItem(props: PostListItemProps) {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const userInfo = useSelector(selectUserInfo);
const [liked, setLiked] = useState(
props.post.likedBy?.some((user) => user.id === userInfo.id) || false
);
const [numberOfLikes, setNumberOfLikes] = useState(
props.post.likedBy?.length || 0
);
useEffect(() => {
setLiked(
props.post.likedBy?.some((user) => user.id === userInfo.id) || false
);
setNumberOfLikes(props.post.likedBy?.length || 0);
}, [props.post, userInfo.id]);
const handleLike = () => {
if (!liked) {
setNumberOfLikes(numberOfLikes + 1);
dispatch(likePost(props.post.id as number, props.feedType));
}
// If the user has already liked the post, unlike it
else {
setNumberOfLikes(numberOfLikes - 1);
dispatch(unLikePost(props.post.id as number, props.feedType));
}
setLiked(!liked);
};
const handlePostClick = () => {
navigate(`/post/${props.post.id}`);
};
const handleUserClick = () => {
navigate(`/user/${props.post.createdBy?.id}`);
};
return (
<ListItem
sx={{
display: "flex",
flexDirection: "column",
mt: "1rem",
justifyContent: "start",
alignItems: "start",
}}
>
<Button
onClick={handleUserClick}
sx={{
textTransform: "none",
display: `${props.postType === "user" ? "none" : "block"}`,
}}
>
<Typography
gutterBottom
sx={{ textAlign: "left", width: "100%" }}
variant="body2"
component="div"
color="text.secondary"
>
{props.post.createdBy?.firstName} {props.post.createdBy?.lastName}
</Typography>
</Button>
<Card sx={{ maxWidth: 400, width: "100%" }}>
<CardActionArea sx={{ mb: "0.8rem" }} onClick={handlePostClick}>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{props.post.title}
</Typography>
<Typography variant="h6" color="text.secondary">
{props.post.content}
</Typography>
</CardContent>
</CardActionArea>
<Grid
container
spacing={1}
sx={{ display: "flex", justifyContent: "end" }}
>
<StyledGrid item>
<Button onClick={handleLike}>
<StyledTypography variant="body2" color="text.secondary">
{numberOfLikes}
</StyledTypography>
{liked ? <ThumbUpAltIcon /> : <ThumbUpOffAltIcon />}
</Button>
</StyledGrid>
<StyledGrid item>
<Button onClick={handlePostClick}>
<StyledTypography variant="body2" color="text.secondary">
{props.post.comments?.length || 0}
</StyledTypography>
<MessageIcon />
</Button>
</StyledGrid>
</Grid>
</Card>
</ListItem>
);
}
const StyledGrid = styled(Grid)({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "auto",
marginBottom: "0.8rem",
marginRight: "0.8rem",
});
const StyledTypography = styled(Typography)({
padding: "0 0.5rem",
});

View File

@ -0,0 +1,93 @@
import {
Divider,
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
} from "@mui/material";
import FeedIcon from "@mui/icons-material/Feed";
import GlobalIcon from "@mui/icons-material/Public";
import AddIcon from "@mui/icons-material/Add";
import SearchIcon from "@mui/icons-material/Search";
import { Link, useNavigate } from "react-router-dom";
export interface SideAppBarProps {
drawerWidth: number;
}
export default function SideAppBar(props: SideAppBarProps) {
const { drawerWidth } = props;
const navigate = useNavigate();
const handleClick = (to: string) => () => {
navigate(to);
};
return (
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
},
}}
variant="permanent"
anchor="left"
>
<Toolbar />
<Divider />
<List>
<ListItem key={"myfeed"} disablePadding onClick={handleClick("feed")}>
<ListItemButton>
<ListItemIcon>
<FeedIcon />
</ListItemIcon>
<ListItemText primary={"My feed"} />
</ListItemButton>
</ListItem>
<ListItem
key={"globalfeed"}
disablePadding
onClick={handleClick("global")}
>
<ListItemButton>
<ListItemIcon>
<GlobalIcon />
</ListItemIcon>
<ListItemText primary={"Global feed"} />
</ListItemButton>
</ListItem>
<ListItem
key={"newpost"}
disablePadding
onClick={handleClick("newpost")}
>
<ListItemButton>
<ListItemIcon>
<AddIcon />
</ListItemIcon>
<ListItemText primary={"New Post"} />
</ListItemButton>
</ListItem>
<ListItem key={"search"} disablePadding onClick={handleClick("search")}>
<ListItemButton>
<ListItemIcon>
<SearchIcon />
</ListItemIcon>
<ListItemText primary={"Search"} />
</ListItemButton>
</ListItem>
</List>
</Drawer>
);
}

View File

@ -0,0 +1,123 @@
import * as React from "react";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import Tooltip from "@mui/material/Tooltip";
import MenuItem from "@mui/material/MenuItem";
import NotificationBell from "./notificationBell";
import {useSelector} from "react-redux";
import { postLogout, selectUserInfo } from "../app/loginSlice";
import { useAppDispatch } from "../app/store";
import { AppAvatar } from "./appAvatar";
import { useNavigate } from "react-router-dom";
interface TopAppBarProps {
height: number;
}
function TopAppBar(props: TopAppBarProps) {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const userInfo = useSelector(selectUserInfo);
const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(
null
);
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget);
};
const handleCloseUserMenu = () => {
setAnchorElUser(null);
};
const handleLogout = () => {
dispatch(postLogout());
setAnchorElUser(null);
};
const handleProfileClick = () => {
navigate("/me");
setAnchorElUser(null);
};
const handleSettingsClick = () => {
navigate("/settings");
setAnchorElUser(null);
};
return (
<AppBar
position="fixed"
sx={{ zIndex: 1600, height: `${props.height}px` }}
>
<Toolbar disableGutters>
<Typography
variant="h6"
noWrap
component="a"
sx={{
marginLeft: "1rem",
mr: 2,
display: "flex",
fontFamily: "monospace",
fontWeight: 700,
letterSpacing: ".3rem",
color: "inherit",
textDecoration: "none",
}}
>
DevSpace
</Typography>
<Box
sx={{
flexGrow: 1,
display: "flex",
justifyContent: "right",
padding: "1rem",
}}
>
<NotificationBell />
</Box>
<Box sx={{ flexGrow: 0, mr: "2rem" }}>
<Tooltip title="Open settings">
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
<AppAvatar user={userInfo} />
</IconButton>
</Tooltip>
<Menu
sx={{ mt: "45px" }}
id="menu-appbar"
anchorEl={anchorElUser}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={Boolean(anchorElUser)}
onClose={handleCloseUserMenu}
>
<MenuItem key={"profile"} onClick={handleProfileClick}>
<Typography textAlign="center">{"Profile"}</Typography>
</MenuItem>
<MenuItem key={"settings"} onClick={handleSettingsClick}>
<Typography textAlign="center">{"Settings"}</Typography>
</MenuItem>
<MenuItem key={"logout"} onClick={handleLogout}>
<Typography textAlign="center">{"Logout"}</Typography>
</MenuItem>
</Menu>
</Box>
</Toolbar>
</AppBar>
);
}
export default TopAppBar;

29
client/src/error-page.tsx Normal file
View File

@ -0,0 +1,29 @@
import {useRouteError} from "react-router-dom";
import {Box, Typography} from "@mui/material";
export default function ErrorPage() {
const error = useRouteError() as any;
console.error(error);
return (
<div id="error-page">
<Box sx={{
marginTop: 16,
display: "flex",
flexDirection: "column",
alignItems: "center"
}}>
<Typography component={"h1"} variant={"h1"}>
Whoops!
</Typography>
<Typography component={"p"}>
Something went wrong :(
</Typography>
<span style={{height: 16}}/>
<Typography component={"p"}>
<i>{error.message || error.statusText}</i>
</Typography>
</Box>
</div>
)
}

View File

@ -1,15 +1,103 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import "./index.css";
import App from './App'; import reportWebVitals from "./reportWebVitals";
import reportWebVitals from './reportWebVitals'; import "@fontsource/roboto";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Root from "./routes/root";
import ErrorPage from "./error-page";
import { CssBaseline } from "@mui/material";
import Login from "./routes/Auth/login";
import Register from "./routes/Auth/register";
import AuthRoot from "./routes/Auth/authRoot";
import { Provider } from "react-redux";
import { store } from "./app/store";
import PostList from "./routes/postList";
import Profile from "./routes/profile";
import PostView from "./routes/post";
import NewPost from "./routes/newPost";
import Search from "./routes/search";
import Settings from "./routes/settings";
import ANotification from "./routes/notification";
import AppThemeProvider from "./app/AppThemeProvider";
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById("root") as HTMLElement
); );
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
children: [
{
path: "feed",
element: <PostList type="feed" feedType="followed" />,
},
{
path: "global",
element: <PostList type="feed" feedType="global" />,
},
{
path: "me",
element: <Profile selfProfile />,
},
{
path: "user/:id",
element: <Profile />,
},
{
path: "post/:id",
element: <PostView />,
},
{
path: "post/:id/edit",
element: <NewPost />,
},
{
path: "search",
element: <Search />,
},
{
path: "newpost",
element: <NewPost />,
},
{
path: "settings",
element: <Settings />,
},
{
path: "notifications",
element: <ANotification />,
},
],
},
{
path: "/auth",
element: <AuthRoot />,
errorElement: <ErrorPage />,
children: [
{
path: "login",
element: <Login />,
},
{
path: "register",
element: <Register />,
},
],
},
]);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <Provider store={store}>
<AppThemeProvider>
<CssBaseline />
<RouterProvider router={router} />
</AppThemeProvider>
</Provider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -0,0 +1,54 @@
import { Copyright } from "@mui/icons-material";
import { Grid, Typography } from "@mui/material";
import { Outlet, useNavigate } from "react-router-dom";
import { StyledDivider } from "../../components/StyledComponents";
import { useSelector } from "react-redux";
import { selectLoggedIn } from "../../app/loginSlice";
import { useEffect } from "react";
export default function AuthRoot() {
const loggedIn = useSelector(selectLoggedIn);
const navigate = useNavigate();
useEffect(() => {
if (loggedIn) {
return navigate("/feed");
}
});
return (
<Grid
container
alignItems="center"
justifyContent="center"
sx={{
minHeight: "100vh",
backgroundColor: (theme) =>
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[900],
}}
direction="column"
spacing={5}
>
<Grid
item
container
alignItems="center"
direction="column"
justifyContent="center"
>
<Typography variant="h1" fontSize={"4.5rem"}>
DevSpace
<StyledDivider />
</Typography>
<Grid item>
<Outlet />
</Grid>
</Grid>
<Grid item>
<Copyright />
</Grid>
</Grid>
);
}

View File

@ -0,0 +1,117 @@
import {
Box,
Button,
Checkbox,
Container,
FormControlLabel,
Grid,
Paper,
TextField,
Typography,
styled,
} from "@mui/material";
import { StyledDivider } from "../../components/StyledComponents";
import { Link } from "@mui/material";
import { useAppDispatch } from "../../app/store";
import { postLogin, selectErrorMessage } from "../../app/loginSlice";
import { AuthLoginPostRequest } from "../../api";
import { useSelector } from "react-redux";
export default function Login() {
const dispatch = useAppDispatch();
const errorMessage = useSelector(selectErrorMessage);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const paramsObject = {
email: data.get("email"),
password: data.get("password"),
longExpiration: data.get("longExpiration"),
};
dispatch(postLogin(paramsObject as unknown as AuthLoginPostRequest));
};
return (
<>
<Container maxWidth="sm" sx={{ marginTop: "15%" }}>
<Grid
container
direction="column"
justifyContent={"center"}
alignItems={"center"}
spacing={2}
sx={{ padding: "10%" }}
>
<Grid item xs={12}>
<Grid
item
component="form"
onSubmit={handleSubmit}
container
direction="column"
spacing={3}
>
<Grid item>
<StyledTypography>Email:</StyledTypography>
<TextField
fullWidth
required
label="Email"
name="email"
variant="outlined"
autoComplete="email"
autoFocus
/>
</Grid>
<Grid item>
<StyledTypography>Password:</StyledTypography>
<TextField
fullWidth
required
label="Password"
name="password"
type="password"
variant="outlined"
error={!!errorMessage}
helperText={errorMessage}
/>
</Grid>
<Grid item>
<FormControlLabel
id="longExpiration"
control={<Checkbox value="remember" color="primary" />}
label="Remember me"
/>
</Grid>
<Grid item>
<Button type="submit" variant="contained" fullWidth={true}>
Login
</Button>
</Grid>
</Grid>
</Grid>
<Grid item>
<StyledDivider />
<Typography variant="h5" fontSize={"1.5rem"}>
Don't have an account yet?
</Typography>
</Grid>
<StyledGrid item>
<Link href="../auth/register" variant="body2">
Register now!
</Link>
</StyledGrid>
</Grid>
</Container>
</>
);
}
const StyledGrid = styled(Grid)({
width: "50%",
});
const StyledTypography = styled(Typography)({
marginBottom: "5%",
});

View File

@ -0,0 +1,123 @@
import {
Button,
Container,
Grid,
Link,
Paper,
TextField,
Typography,
styled,
} from "@mui/material";
import { useAppDispatch } from "../../app/store";
import { postSignup } from "../../app/loginSlice";
import { AuthSignupPostRequest } from "../../api";
export default function Register() {
const dispatch = useAppDispatch();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const paramsObject = {
email: data.get("email"),
password: data.get("password"),
passwordValidation: data.get("passwordConfirmation"),
firstName: data.get("firstName"),
lastName: data.get("lastName"),
longExpiration: true,
};
dispatch(postSignup(paramsObject as unknown as AuthSignupPostRequest));
};
return (
<>
<Container maxWidth="sm" sx={{ marginTop: "5%" }}>
<Grid
component="form"
onSubmit={handleSubmit}
container
direction="column"
justifyContent={"center"}
alignItems={"center"}
spacing={2}
sx={{ paddingBottom: "10%" }}
>
<Grid item container xs={12} spacing={2} sx={{ width: "70%" }}>
<Grid item xs={12}>
<StyledTypography>First Name:</StyledTypography>
<TextField
fullWidth
label="First Name"
name="firstName"
required
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<StyledTypography>Last Name:</StyledTypography>
<TextField
fullWidth
name="lastName"
required
label="Last Name"
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<StyledTypography>Email:</StyledTypography>
<TextField
fullWidth
label="Email"
name="email"
required
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<StyledTypography>Password:</StyledTypography>
<TextField
type="password"
name="password"
required
label="Password"
fullWidth
/>
</Grid>
<Grid item xs={12}>
<StyledTypography>Password Confirmation:</StyledTypography>
<TextField
type="password"
name="passwordConfirmation"
label="Password Confirmation"
fullWidth
required
/>
</Grid>
</Grid>
<StyledGrid item xs={12}>
<Button type="submit" variant="contained" fullWidth={true}>
Register
</Button>
</StyledGrid>
<Grid item>
<Typography variant="h5" fontSize={"1.5rem"}>
Already have an account?
</Typography>
</Grid>
<StyledGrid item>
<Link href="../auth/login" variant="body2">
Login
</Link>
</StyledGrid>
</Grid>
</Container>
</>
);
}
const StyledGrid = styled(Grid)({
width: "50%",
});
const StyledTypography = styled(Typography)({
marginBottom: "5%",
});

View File

@ -0,0 +1,93 @@
import { Box, Button, TextField, Typography } from "@mui/material";
import { useAppDispatch } from "../app/store";
import React, { useEffect } from "react";
import {
createPost,
selectAPost,
updatePost,
} from "../app/postSlice";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
export default function NewPost() {
const { id } = useParams();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [newPostText, setNewPostText] = React.useState("");
const [newPostTitle, setNewPostTitle] = React.useState("");
const aPost = useSelector(selectAPost);
useEffect(() => {
if (id) {
setNewPostText(aPost.content);
setNewPostTitle(aPost.title);
}
}, [aPost, id]);
const handleCreatePost = () => {
if (newPostText === "" || newPostTitle === "") return;
dispatch(createPost(newPostTitle, newPostText));
setNewPostText("");
setNewPostTitle("");
navigate("/global");
};
const handleUpdatePost = () => {
if (newPostText === "" || newPostTitle === "") return;
if (!id) return;
dispatch(updatePost(parseInt(id), newPostTitle, newPostText));
setNewPostText("");
setNewPostTitle("");
navigate("/global");
};
return (
<Box
sx={{
margin: "auto",
display: "flex",
flexDirection: "column",
}}
>
<Typography variant="h4" sx={{ marginBottom: 2 }}>
Share your thoughts!
</Typography>
<TextField
label="Title"
value={newPostTitle}
multiline
onChange={(e) => setNewPostTitle(e.target.value)}
sx={{ marginBottom: 2 }}
/>
<TextField
label="Post"
value={newPostText}
onChange={(e) => setNewPostText(e.target.value)}
multiline
rows={4}
sx={{ marginBottom: 2 }}
/>
{id ? (
<Button
variant="contained"
color="success"
fullWidth
onClick={handleUpdatePost}
>
Update
</Button>
) : (
<Button
variant="contained"
color="primary"
fullWidth
onClick={handleCreatePost}
>
Create
</Button>
)}
</Box>
);
}

View File

@ -0,0 +1,31 @@
import { useSelector } from "react-redux";
import { selectUserInfo } from "../app/loginSlice";
import { Box, List, ListItem, Paper } from "@mui/material";
export default function ANotification() {
const userInfo = useSelector(selectUserInfo);
return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
<List sx={{ width: "100%" }}>
{userInfo.notifications.length === 0 ? (
<ListItem sx={{ width: "100%" }}>No notifications</ListItem>
) : (
userInfo.notifications.map((notification: any) => (
<ListItem key={notification.id} sx={{ width: "100%" }}>
<Paper
sx={{
width: "100%",
margin: "1rem",
padding: "0.2rem 1rem 0.2rem 1rem",
}}
>
{notification.message}
</Paper>
</ListItem>
))
)}
</List>
</Box>
);
}

155
client/src/routes/post.tsx Normal file
View File

@ -0,0 +1,155 @@
import { useSelector } from "react-redux";
import { useNavigate, useParams } from "react-router-dom";
import {
commentAPost,
deletePost,
fetchAPost,
selectAPost,
} from "../app/postSlice";
import { useAppDispatch } from "../app/store";
import { useEffect } from "react";
import React from "react";
import { Post } from "../api";
import {
Box,
Button,
Divider,
Paper,
TextField,
Typography,
} from "@mui/material";
import { AppAvatar } from "../components/appAvatar";
import { selectUserInfo } from "../app/loginSlice";
export default function PostView() {
const postId = useParams<{ id: string }>().id;
const storePost = useSelector(selectAPost);
const selfUser = useSelector(selectUserInfo);
const [postToDisplay, setPostToDisplay] = React.useState<Post | null>(null);
const [newComentText, setNewCommentText] = React.useState("");
const dispatch = useAppDispatch();
const navigate = useNavigate();
useEffect(() => {
dispatch(fetchAPost(parseInt(postId!, 10)));
}, []);
useEffect(() => {
setPostToDisplay(storePost);
}, [storePost]);
const handleCommentPost = () => {
if (newComentText.length > 0) {
dispatch(commentAPost(postToDisplay!.id!, newComentText));
setNewCommentText("");
}
};
const handleCancelComment = () => {
setNewCommentText("");
};
const handleDeletePost = () => {
dispatch(deletePost(postToDisplay!.id!));
navigate("/global");
};
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "60%",
margin: "auto",
minWidth: "330px",
}}
>
<Box sx={{ width: "100%" }}>
<Paper>
<Box sx={{ margin: "1rem" }}>
<Box
sx={{ display: "flex", alignItems: "center", paddingTop: "1rem" }}
>
{postToDisplay?.createdBy && (
<AppAvatar user={postToDisplay?.createdBy!} />
)}
<Typography
variant="subtitle2"
sx={{ mb: "0.2rem", ml: "0.3rem" }}
>
{postToDisplay?.createdBy?.firstName}{" "}
{postToDisplay?.createdBy?.lastName}
</Typography>
</Box>
<Divider sx={{ mb: "0.3rem" }} />
<Typography variant="h4">{postToDisplay?.title}</Typography>
<Typography variant="body1">{postToDisplay?.content}</Typography>
</Box>
</Paper>
</Box>
<Box sx={{ width: "100%" }}>
{postToDisplay?.createdBy?.id === selfUser?.id && (
<Box>
<Button onClick={() => navigate("edit")}>Edit</Button>
<Button color="warning" onClick={handleDeletePost}>
Delete
</Button>
</Box>
)}
<TextField
label="Add a comment..."
fullWidth
multiline
value={newComentText}
onChange={(e) => setNewCommentText(e.target.value)}
InputProps={{
endAdornment: (
<>
<Button
variant="contained"
onClick={handleCommentPost}
sx={{
display: `${newComentText.length > 0 ? "inherit" : "none"}`,
}}
>
Accept
</Button>
<Button
variant="outlined"
onClick={handleCancelComment}
sx={{
display: `${newComentText.length > 0 ? "inherit" : "none"}`,
ml: "0.5rem",
}}
>
Cancel
</Button>
</>
),
}}
sx={{
mb: "1rem",
}}
/>
<Box>
{postToDisplay?.comments?.map((comment) => (
<Paper>
<Box sx={{ margin: "1rem" }}>
<Box sx={{ display: "flex", mb: "0.3rem", pt: "0.3rem" }}>
<AppAvatar user={comment.createdBy!} small />
<Typography variant="subtitle2" sx={{ ml: "0.5rem" }}>
{comment.createdBy?.firstName} {comment.createdBy?.lastName}
</Typography>
</Box>
<Divider sx={{ mb: "0.3rem" }} />
<Typography variant="body1">{comment.content}</Typography>
</Box>
</Paper>
))}
</Box>
</Box>
</Box>
);
}

View File

@ -0,0 +1,121 @@
import { useSelector } from "react-redux";
import {
fetchFollowedPosts,
fetchGlobalPosts,
selectAllPosts,
selectFollowedPosts,
selectStatus,
} from "../app/postSlice";
import { store, useAppDispatch } from "../app/store";
import { useEffect, useState } from "react";
import { Box, CircularProgress, List, Typography } from "@mui/material";
import { Post } from "../api";
import PostListItem from "../components/postListItem";
import { Status } from "../util/types";
interface PostListProps {
type: "feed" | "user";
feedType?: "global" | "followed";
}
export default function PostList(props: PostListProps) {
const dispatch = useAppDispatch();
const followedPosts = useSelector(selectFollowedPosts);
const globalPosts = useSelector(selectAllPosts);
const status = useSelector(selectStatus);
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
if (props.feedType === "global") {
dispatch(fetchGlobalPosts());
} else {
dispatch(fetchFollowedPosts());
}
}, [props.feedType]);
useEffect(() => {
let postsToDisplay: Post[];
if (props.feedType === "global") {
postsToDisplay = globalPosts;
} else {
postsToDisplay = followedPosts;
}
let sortedPosts: Post[] = [];
if (postsToDisplay.length > 0) {
sortedPosts = [...postsToDisplay].sort((a: Post, b: Post) => {
return b.createdAt! > a.createdAt!
? 1
: b.createdAt! < a.createdAt!
? -1
: 0;
});
}
setPosts(sortedPosts);
}, [followedPosts, globalPosts, props.feedType]);
return (
<Box
sx={{
width: "100%",
alignContent: "center",
justifyContent: "center",
display: "flex",
}}
>
{status === Status.loading ? (
<CircularProgress />
) : (
<>
{props.feedType === "global" && (
<List>
{posts.map((post: Post) => (
<PostListItem
post={post}
postType="feed"
feedType="global"
key={post.id}
/>
))}
</List>
)}
{props.feedType === "followed" && (
<List>
{followedPosts.map((post: Post) => (
<PostListItem
post={post}
postType="feed"
feedType="followed"
key={post.id}
/>
))}
{followedPosts.length === 0 && (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Typography
variant="h3"
sx={{ color: "text.secondary", mb: "2rem" }}
>
Whops!
</Typography>
<Typography
textAlign="center"
sx={{ color: "text.secondary" }}
>
There is no content here! Try following some users, or check
the global feed
</Typography>
</Box>
)}
</List>
)}
</>
)}
</Box>
);
}

View File

@ -0,0 +1,169 @@
import {
Box,
Button,
CircularProgress,
List,
Paper,
Typography,
} from "@mui/material";
import { useParams } from "react-router-dom";
import { AppAvatar } from "../components/appAvatar";
import { useAppDispatch } from "../app/store";
import { useSelector } from "react-redux";
import {
followUser,
getUserWithRelations,
selectAUser,
selectStatus,
unFollowUser,
} from "../app/usersSlice";
import { useEffect } from "react";
import { selectUserInfo } from "../app/loginSlice";
import PostListItem from "../components/postListItem";
import { Post, UserWithRelations } from "../api";
import React from "react";
import { Status } from "../util/types";
export interface ProfileProps {
selfProfile?: boolean;
}
export default function Profile(props: ProfileProps) {
const userId = useParams<{ id: string }>().id;
const [userToDisplay, setUserToDisplay] =
React.useState<UserWithRelations | null>();
const aUser = useSelector(selectAUser);
const [isFollowed, setIsFollowed] = React.useState(false);
const status = useSelector(selectStatus);
const selfUser = useSelector(selectUserInfo);
const dispatch = useAppDispatch();
useEffect(() => {
if (!props.selfProfile && userId) {
dispatch(getUserWithRelations(parseInt(userId, 10)));
} else {
dispatch(getUserWithRelations(selfUser.id));
}
}, []);
useEffect(() => {
setUserToDisplay(aUser);
if (!props.selfProfile) {
setIsFollowed(
aUser?.followers?.some((user) => user.id === selfUser.id) || false
);
}
}, [aUser, props.selfProfile, selfUser.id]);
const handleFollow = () => {
if (isFollowed) {
dispatch(unFollowUser(userToDisplay!.id!));
} else {
dispatch(followUser(userToDisplay!.id!));
}
setIsFollowed((prevState) => !prevState);
};
return (
<>
<Box
sx={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
{status === Status.loading && (
<>
<Typography variant="h3">Loading...</Typography>
<CircularProgress />
</>
)}
{userToDisplay && status === Status.idle && (
<>
<Paper
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<Box>
<Box
sx={{
display: "flex",
padding: "1rem 1rem",
}}
>
<AppAvatar user={userToDisplay!} />
<Typography variant="h4" sx={{ ml: "0.8rem" }}>
{userToDisplay?.firstName} {userToDisplay?.lastName}
</Typography>
</Box>
<Box sx={{ display: "flex", padding: "1rem 1rem" }}>
<Box sx={{ display: "flex" }}>
<Typography
variant="h6"
sx={{ ml: "0.8rem", mr: "0.5rem" }}
>
Following:
</Typography>
<Typography variant="h6">
{userToDisplay?.followed?.length || 0}
</Typography>
</Box>
<Box sx={{ display: "flex" }}>
<Typography
variant="h6"
sx={{ ml: "0.8rem", mr: "0.5rem" }}
>
Followers:{" "}
</Typography>
<Typography variant="h6">
{userToDisplay?.followers?.length || 0}
</Typography>
</Box>
<Box
sx={{
display: `${props.selfProfile ? "none" : "initial"}`,
ml: "0.8rem",
}}
>
<Button
variant={isFollowed ? "outlined" : "contained"}
onClick={handleFollow}
>
{isFollowed ? "Unfollow" : "Follow"}
</Button>
</Box>
</Box>
</Box>
</Paper>
<Box
sx={{
width: "75%",
margin: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<List>
{userToDisplay!.posts?.map((post: Post) => (
<PostListItem post={post} postType="user" key={post.id} />
)) || (
<Typography variant="h3">
This user has no posts yet!
</Typography>
)}
</List>
</Box>
</>
)}
</Box>
</>
);
}

View File

@ -0,0 +1,64 @@
import { Outlet, useNavigate } from "react-router-dom";
import { Box } from "@mui/material";
import { selectLoggedIn } from "../app/loginSlice";
import { useSelector } from "react-redux";
import { useEffect } from "react";
import useWindowDimensions from "../app/hooks/useWindowDimensions";
import SideAppBar from "../components/sideAppBar";
import BottomAppBar from "../components/bottomAppBar";
import TopAppBar from "../components/topAppBar";
const drawerWidth = 240;
const topAppBarHeight = 64;
export default function Root() {
const navigate = useNavigate();
const loggedIn = useSelector(selectLoggedIn);
const { width } = useWindowDimensions();
useEffect(() => {
if (!loggedIn) {
navigate("/auth/login");
}
}, [loggedIn, navigate]);
return (
<>
<Box>
<TopAppBar height={topAppBarHeight} />
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
flexDirection: { xs: "column", sm: "row" },
}}
>
{width > 600 && (
<Box sx={{ flexBasis: { sm: "auto" } }}>
<SideAppBar drawerWidth={drawerWidth} />
</Box>
)}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - ${width > 600 ? drawerWidth : 0}px)` },
marginTop: { xs: `${topAppBarHeight}px`},
}}
>
<Outlet />
</Box>
{width <= 600 && (
<Box sx={{ flexBasis: "100%" }}>
<BottomAppBar />
</Box>
)}
</Box>
</>
);
}

View File

@ -0,0 +1,87 @@
import { useSelector } from "react-redux";
import { getUsers, selectUsers } from "../app/usersSlice";
import React, { MouseEventHandler, useEffect } from "react";
import { useAppDispatch } from "../app/store";
import {
Box,
Button,
Divider,
List,
ListItem,
ListItemText,
Paper,
TextField,
} from "@mui/material";
import { AppAvatar } from "../components/appAvatar";
import { useNavigate } from "react-router-dom";
import { matchSorter } from "match-sorter";
export default function Search() {
const userList = useSelector(selectUsers);
const [displayUserList, setDisplayUserList] = React.useState(userList);
const dispatch = useAppDispatch();
const navigate = useNavigate();
// Dispatch the getUsers actions, and set it as the displayUserList
// once on the first render
useEffect(() => {
dispatch(getUsers());
}, []);
useEffect(() => {
setDisplayUserList(userList);
}, [userList]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const searchValue = event.currentTarget.value;
if (searchValue === "") {
setDisplayUserList(userList);
return;
}
setDisplayUserList(
matchSorter(userList, searchValue, { keys: ["firstName", "lastName"] })
);
};
const handleClick: MouseEventHandler = (event) => {
const userId = event.currentTarget.id;
console.log("User Id from the search component: ", userId);
navigate(`/user/${userId}`);
};
return (
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Paper sx={{ mb: "2rem" }}>
<TextField
sx={{ width: "100%" }}
label="Search for a user..."
onChange={handleSearchChange}
/>
</Paper>
<Paper>
<List>
{displayUserList.map((user) => (
<Box key={user.id}>
<ListItem key={user.id}>
<Button
fullWidth
sx={{ textAlign: "start" }}
id={user.id?.toString()}
onClick={handleClick}
>
<AppAvatar user={user} />
<ListItemText
sx={{ ml: "2rem" }}
primary={`${user.firstName} ${user.lastName}`}
/>
</Button>
</ListItem>
<Divider sx={{ mb: "0.3rem" }} />
</Box>
))}
</List>
</Paper>
</Box>
);
}

View File

@ -0,0 +1,56 @@
import { Box, Checkbox, Divider, Typography } from "@mui/material";
import { useSelector } from "react-redux";
import {
selectDarkMode,
selectUserInfo,
setDarkMode,
updateMe,
} from "../app/loginSlice";
import { useAppDispatch } from "../app/store";
import { useEffect, useState } from "react";
export default function Settings() {
const userInfo = useSelector(selectUserInfo);
const darkModeState = useSelector(selectDarkMode);
const dispatch = useAppDispatch();
const [isPrivate, setIsPrivate] = useState(false);
const [darkModeLocal, setDarkModeLocal] = useState(false);
useEffect(() => {
setIsPrivate(userInfo.isPrivate);
}, [userInfo]);
useEffect(() => {
setDarkModeLocal(darkModeState);
}, [darkModeState]);
const handleDarkChange = () => {
dispatch(setDarkMode(!darkModeLocal));
localStorage.setItem("darkMode", darkModeLocal ? "false" : "true");
};
const handlePrivateChange = () => {
dispatch(updateMe(!isPrivate));
};
return (
<Box>
<Box>
<Checkbox checked={isPrivate} onChange={handlePrivateChange} /> Private
profile
</Box>
<Divider />
<Box>
<Checkbox checked={darkModeLocal} onChange={handleDarkChange} /> Dark
mode
</Box>
<Divider />
<Box>
<Typography variant="h4">More Settings</Typography>
<Typography variant="body1">Coming soon...</Typography>
</Box>
</Box>
);
}

6
client/src/util/types.ts Normal file
View File

@ -0,0 +1,6 @@
export enum Status {
idle = "idle",
loading = "loading",
succeeded = "succeeded",
failed = "failed",
}

View File

@ -1,11 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
@ -20,7 +16,5 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx"
}, },
"include": [ "include": ["src"]
"src"
]
} }

View File

@ -6,6 +6,7 @@ services:
build: ./client build: ./client
environment: environment:
NODE_ENV: development NODE_ENV: development
REACT_APP_BACKEND_URL: http://localhost:3000
ports: ports:
- "8080:3000" - "8080:3000"
server: server:

7
openapitools.json Normal file
View File

@ -0,0 +1,7 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.2.0"
}
}

296
server/package-lock.json generated
View File

@ -10,21 +10,69 @@
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"body-parser": "^1.19.1", "body-parser": "^1.19.1",
"cors": "^2.8.5",
"express": "^4.17.2", "express": "^4.17.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ms": "^2.1.3",
"mysql": "^2.14.1", "mysql": "^2.14.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"typeorm": "0.3.20" "typeorm": "0.3.20"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"@types/ms": "^0.7.34",
"@types/node": "^16.11.10", "@types/node": "^16.11.10",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "4.5.2" "typescript": "4.5.2"
} }
}, },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
"integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.6",
"call-me-maybe": "^1.0.1",
"js-yaml": "^4.1.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg=="
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^9.0.6",
"@apidevtools/openapi-schemas": "^2.0.4",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"call-me-maybe": "^1.0.1",
"z-schema": "^5.0.1"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@ -78,6 +126,11 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="
},
"node_modules/@mapbox/node-pre-gyp": { "node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@ -163,6 +216,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cors": {
"version": "2.8.17",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
"integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": { "node_modules/@types/express": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz",
@ -193,6 +255,11 @@
"integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
"dev": true "dev": true
}, },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz",
@ -253,6 +320,22 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/swagger-jsdoc": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz",
"integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==",
"dev": true
},
"node_modules/@types/swagger-ui-express": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz",
"integrity": "sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==",
"dev": true,
"dependencies": {
"@types/express": "*",
"@types/serve-static": "*"
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -397,6 +480,11 @@
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"devOptional": true "devOptional": true
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@ -527,6 +615,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -748,6 +841,14 @@
"color-support": "bin.js" "color-support": "bin.js"
} }
}, },
"node_modules/commander": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
"integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
"engines": {
"node": ">= 6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -795,6 +896,18 @@
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
}, },
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/create-require": { "node_modules/create-require": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@ -884,6 +997,17 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
"integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.1", "version": "16.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz",
@ -939,6 +1063,14 @@
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
}, },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/etag": { "node_modules/etag": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -1423,6 +1555,17 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@ -1463,6 +1606,11 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ=="
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -1473,6 +1621,11 @@
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
},
"node_modules/lodash.isinteger": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@ -1493,6 +1646,11 @@
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
}, },
"node_modules/lodash.mergewith": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
"integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="
},
"node_modules/lodash.once": { "node_modules/lodash.once": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
@ -1770,6 +1928,12 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"peer": true
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
@ -2264,6 +2428,94 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/swagger-jsdoc": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
"integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
"dependencies": {
"commander": "6.2.0",
"doctrine": "3.0.0",
"glob": "7.1.6",
"lodash.mergewith": "^4.6.2",
"swagger-parser": "^10.0.3",
"yaml": "2.0.0-1"
},
"bin": {
"swagger-jsdoc": "bin/swagger-jsdoc.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/swagger-jsdoc/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/swagger-jsdoc/node_modules/glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/swagger-jsdoc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/swagger-parser": {
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
"integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.11.2",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.11.2.tgz",
"integrity": "sha512-jQG0cRgJNMZ7aCoiFofnoojeSaa/+KgWaDlfgs8QN+BXoGMpxeMVY5OEnjq4OlNvF3yjftO8c9GRAgcHlO+u7A=="
},
"node_modules/swagger-ui-express": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.0.tgz",
"integrity": "sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==",
"dependencies": {
"swagger-ui-dist": ">=5.0.0"
},
"engines": {
"node": ">= v0.10.32"
},
"peerDependencies": {
"express": ">=4.0.0 || >=5.0.0-beta"
}
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
@ -2574,6 +2826,14 @@
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"devOptional": true "devOptional": true
}, },
"node_modules/validator": {
"version": "13.11.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz",
"integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -2754,6 +3014,14 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"node_modules/yaml": {
"version": "2.0.0-1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
"integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
"engines": {
"node": ">= 6"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@ -2824,6 +3092,34 @@
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
},
"node_modules/z-schema": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
"integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
"dependencies": {
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"validator": "^13.7.0"
},
"bin": {
"z-schema": "bin/z-schema"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"commander": "^9.4.1"
}
},
"node_modules/z-schema/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"optional": true,
"engines": {
"node": "^12.20.0 || >=14"
}
} }
} }
} }

View File

@ -5,21 +5,27 @@
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
"@types/node": "^16.11.10", "@types/node": "^16.11.10",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.6",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "4.5.2" "typescript": "4.5.2"
}, },
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"body-parser": "^1.19.1", "body-parser": "^1.19.1",
"cors": "^2.8.5",
"express": "^4.17.2", "express": "^4.17.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ms": "^2.1.3", "ms": "^2.1.3",
"mysql": "^2.14.1", "mysql": "^2.14.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"typeorm": "0.3.20" "typeorm": "0.3.20"
}, },
"scripts": { "scripts": {

View File

@ -1,53 +1,370 @@
import { AppDataSource } from "../data-source" import { AppDataSource } from "../data-source"
import { NextFunction, Request, Response } from "express" import { NextFunction, Request, Response } from "express"
import { User } from "../entity/User" import { User } from "../entity/User"
import {AppError} from "../util/AppError";
import {AppRequest} from "../util/AppRequest";
import {Notification} from "../entity/Notification";
import {catchAsync} from "../util/catchAsync";
export class UserController { export class UserController {
private userRepository = AppDataSource.getRepository(User);
private userRepository = AppDataSource.getRepository(User) private notificationRepository = AppDataSource.getRepository(Notification);
/**
* @swagger
* /users:
* get:
* security:
* - bearerAuth: []
* tags:
* - Users
* summary: Get all users
* responses:
* 200:
* description: A list of all users
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/User'
*/
public getAllUsers = catchAsync(
async (req: Request, res: Response, next: NextFunction) => {
const users = await this.userRepository.find();
async all(request: Request, response: Response, next: NextFunction) { // remove sensitive fields
return this.userRepository.find() users.forEach((user) => {
user.deleteSensitiveFields();
});
// remove the users set to private
const publicUsers = users.filter((user) => user.isPrivate === false);
res.status(200).send(publicUsers);
} }
);
async one(request: Request, response: Response, next: NextFunction) { /**
const id = parseInt(request.params.id) * @swagger
* /users/{id}:
* get:
* security:
* - bearerAuth: []
* tags:
* - Users
* summary: Get a single user
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the user
* schema:
* type: integer
* responses:
* 200:
* description: A single user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserWithRelations'
*/
public getUser = catchAsync(
async (req: AppRequest, res: Response, next: NextFunction) => {
const id = req.params.id;
const parsedId = parseInt(id);
// Check if ID is a number
if (isNaN(parsedId)) return next(new AppError("Invalid ID", 400));
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { id } where: { id: parsedId },
}) relations: {
followed: true,
followers: true,
posts: { likedBy: true },
comments: true,
},
});
if (!user) { if (!user) return next(new AppError("No user found with that ID", 404));
return "unregistered user"
// remove sensitive fields
user.deletePrivateFields();
if (user.posts) {
user.posts.forEach((post) => {
if (post.likedBy) {
post.likedBy.forEach((user) => user.deleteSensitiveFields());
} }
return user if (post.comments) {
post.comments.forEach((comment) =>
comment.createdBy.deleteSensitiveFields()
);
}
});
}
if (user.followed) {
user.followed.forEach((followedUser) =>
followedUser.deleteSensitiveFields()
);
}
let followStatus = false;
if (user.followers) {
user.followers.forEach((follower) => follower.deleteSensitiveFields());
// Set the followed status of the user
followStatus = user.followers.some(
(follower) => follower.id === req.user.id
);
} }
async save(request: Request, response: Response, next: NextFunction) { // If the user is private, only return the user
const { firstName, lastName, age } = request.body; // if the requesting user is following
const user = Object.assign(new User(), { if (user.isPrivate && !followStatus) {
firstName, user.followed = [];
lastName, user.followers = [];
age user.posts = [];
}) user.comments = [];
return this.userRepository.save(user)
} }
async remove(request: Request, response: Response, next: NextFunction) { return res.send(user);
const id = parseInt(request.params.id)
let userToRemove = await this.userRepository.findOneBy({ id })
if (!userToRemove) {
return "this user not exist"
} }
);
await this.userRepository.remove(userToRemove) /**
* @swagger
* /users/me:
* get:
* tags:
* - Users
* summary: Get the currently logged in user
* security:
* - bearerAuth: []
* responses:
* 200:
* description: The currently logged in user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserWithRelationsAndNotifications'
*/
public getMe = catchAsync(
async (req: AppRequest, res: Response, next: NextFunction) => {
const user = await this.userRepository.findOne({
where: { id: req.user.id },
relations: {
followed: true,
followers: true,
posts: true,
comments: true,
notifications: true,
},
});
return "user has been removed" user.followed.forEach((followedUser) =>
followedUser.deleteSensitiveFields()
);
user.followers.forEach((follower) => follower.deleteSensitiveFields());
return res.status(200).send(user);
} }
);
/**
* @swagger
* /users/me:
* patch:
* security:
* - bearerAuth: []
* tags:
* - Users
* summary: Update the currently logged in user
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* isPrivate:
* type: boolean
* description: Whether the user's account is private
* profilePictureId:
* type: string
* description: The ID of the user's profile picture
* responses:
* 200:
* description: Successfully updated the user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserWithRelationsAndNotifications'
* 400:
* description: Invalid request body
*/
public updateMe = catchAsync(
async (req: AppRequest, res: Response, next: NextFunction) => {
const user = await this.userRepository.findOne({
where: { id: req.user.id },
relations: {
followed: true,
followers: true,
posts: true,
comments: true,
notifications: true,
},
});
// Strict check, as we are dealing with a boolean value in this field
user.isPrivate =
req.body.isPrivate === undefined ? user.isPrivate : req.body.isPrivate;
user.profilePictureId =
req.body.profilePictureId || user.profilePictureId;
await this.userRepository.save(user);
return res.status(200).send(user);
}
);
/**
* @swagger
* /users/follow/{id}:
* post:
* security:
* - bearerAuth: []
* tags:
* - Users
* summary: Follow a user
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to follow
* responses:
* 200:
* description: Successfully followed the user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserWithRelations'
* 400:
* description: Invalid ID
* 404:
* description: No user found with that ID
*/
public followUser = catchAsync(
async (req: AppRequest, res: Response, next: NextFunction) => {
const userToFollowId = req.params.id;
const parsedId = parseInt(userToFollowId);
// Check if ID is a number
if (isNaN(parsedId)) return next(new AppError("Invalid ID", 400));
const user = req.user;
const userToFollow = await this.userRepository.findOne({
where: { id: parsedId },
relations: { followed: true, followers: true, notifications: true },
});
if (!userToFollow)
return next(new AppError("No user found with that ID", 404));
// Check if user is already following
if (
user.followed.some(
(followedUser) => followedUser.id === userToFollow.id
)
) {
return next(new AppError("You are already following this user", 400));
}
// Follow the user
user.followed.push(userToFollow);
await this.userRepository.save(user);
// Add the requesting user to the followers of the user being followed
userToFollow.followers.push(user);
// Create a notification for the user being followed
const followNotification = Object.assign(new Notification(), {
seen: false,
message: `${user.firstName} is now following you`,
timeStamp: new Date(),
});
userToFollow.notifications.push(
await this.notificationRepository.save(followNotification)
);
await this.userRepository.save(userToFollow);
return res.status(200).send({
status: "success",
message: `You are now following ${userToFollow.firstName}`,
});
}
);
/**
* @swagger
* /users/follow/{id}:
* delete:
* security:
* - bearerAuth: []
* tags:
* - Users
* summary: Unfollow a user
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: The ID of the user to unfollow
* responses:
* 200:
* description: Successfully unfollowed the user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserWithRelations'
* 400:
* description: Invalid ID
* 404:
* description: No user found with that ID
*/
public unfollowUser = catchAsync(
async (req: AppRequest, res: Response, next: NextFunction) => {
const userToUnfollowId = req.params.id;
const parsedId = parseInt(userToUnfollowId);
// Check if ID is a number
if (isNaN(parsedId)) return next(new AppError("Invalid ID", 400));
const user = req.user;
const userToUnfollow = await this.userRepository.findOne({
where: { id: parsedId },
relations: { followed: true, followers: true },
});
if (!userToUnfollow)
return next(new AppError("No user found with that ID", 404));
// Check if user is following
if (
!user.followed.some(
(followedUser) => followedUser.id === userToUnfollow.id
)
) {
return next(new AppError("You are not following this user", 400));
}
// Unfollow the user
user.followed = user.followed.filter(
(followedUser) => followedUser.id !== userToUnfollow.id
);
await this.userRepository.save(user);
userToUnfollow.followers = userToUnfollow.followers.filter(
(follower) => follower.id !== user.id
);
await this.userRepository.save(userToUnfollow);
return res.status(200).send({
status: "success",
message: `You are no longer following ${userToUnfollow.firstName}`,
});
}
);
} }

View File

@ -38,6 +38,45 @@ export class AuthController {
} }
/**
* @swagger
* /auth/signup:
* post:
* tags:
* - Authentication
* summary: Sign up a new user
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* description: The email of the user
* password:
* type: string
* description: The password of the user
* passwordValidation:
* type: string
* description: Password validation field
* firstName:
* type: string
* description: The first name of the user
* lastName:
* type: string
* description: The last name of the user
* responses:
* 200:
* description: Successfully signed up the user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 400:
* description: Invalid input or user already exists
*/
public handleSignUp = catchAsync(async (req, res, next) => { public handleSignUp = catchAsync(async (req, res, next) => {
const {email, password, passwordValidation, firstName, lastName} = req.body const {email, password, passwordValidation, firstName, lastName} = req.body
// Body Validation // Body Validation
@ -64,10 +103,52 @@ export class AuthController {
this.sendToken(await this.userRepository.save(newUser), res) this.sendToken(await this.userRepository.save(newUser), res)
}) })
/**
* @swagger
* /auth/login:
* post:
* tags:
* - Authentication
* summary: Log in a user
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* description: The email of the user
* password:
* type: string
* description: The password of the user
* longExpiration:
* type: boolean
* description: Whether to keep the user logged in for a long time
* responses:
* 200:
* description: Successfully logged in the user
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "success"
* token:
* type: string
* description: The JWT token for the user
* 400:
* description: Missing email or password
* 401:
* description: Incorrect email or password
*/
public handleLogin = catchAsync(async (req, res, next) => { public handleLogin = catchAsync(async (req, res, next) => {
const {email, password, longExpiration} = req.body const { email, password, longExpiration } = req.body;
if (!email || !password) { if (!email || !password) {
return next(new AppError('Please provide email and password', 400)) return next(new AppError("Please provide email and password", 400));
} }
const user = await this.userRepository.findOne({where: {email}}) const user = await this.userRepository.findOne({where: {email}})
@ -79,14 +160,35 @@ export class AuthController {
this.sendToken(user, res, {long: longExpiration}) this.sendToken(user, res, {long: longExpiration})
}) })
/**
* @swagger
* /auth/logout:
* get:
* security:
* - bearerAuth: []
* tags:
* - Authentication
* summary: Log out the user
* responses:
* 200:
* description: Successfully logged out the user
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "success"
*/
public handleLogout = catchAsync(async (req: AppRequest, res, next) => { public handleLogout = catchAsync(async (req: AppRequest, res, next) => {
// Set the jwt cookie to a dummy value and set the expiration to a date in the past // Set the jwt cookie to a dummy value and set the expiration to a date in the past
res.cookie('jwt', 'loggedout', { res.cookie("jwt", "loggedout", {
expires: new Date(Date.now() - 10000), expires: new Date(Date.now() - 10000),
httpOnly: true httpOnly: true,
}) });
res.status(200).json({status: 'success'}) res.status(200).json({ status: "success" });
}) });
public protect = catchAsync(async (req: AppRequest, res, next) => { public protect = catchAsync(async (req: AppRequest, res, next) => {
let token: string | undefined; let token: string | undefined;
@ -106,7 +208,10 @@ export class AuthController {
// Verify the token // Verify the token
const decoded = jwt.verify(token, this.jwt_secret) as JwtPayload const decoded = jwt.verify(token, this.jwt_secret) as JwtPayload
// Check if the user still exists // Check if the user still exists
const candidateUser = await this.userRepository.findOne({where: {id: decoded.id}}) const candidateUser = await this.userRepository.findOne({
where: {id: decoded.id},
relations: {followed: true, followers: true, notifications: true}
})
if(!candidateUser){ if(!candidateUser){
return next(new AppError('The user belonging to this token no longer exists', 401)) return next(new AppError('The user belonging to this token no longer exists', 401))
} }

View File

@ -0,0 +1,571 @@
import {AppDataSource} from "../data-source";
import {User} from "../entity/User";
import {Post} from "../entity/Post";
import {Comment} from "../entity/Comment";
import {Notification} from "../entity/Notification";
import {catchAsync} from "../util/catchAsync";
import {AppError} from "../util/AppError";
import {AppRequest} from "../util/AppRequest";
export class PostController {
private postRepository = AppDataSource.getRepository(Post);
private commentRepository = AppDataSource.getRepository(Comment);
private notificationRepository = AppDataSource.getRepository(Notification);
private userRepository = AppDataSource.getRepository(User);
/**
* @swagger
* /posts:
* get:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: getAllPosts
* summary: Get all posts
* responses:
* 200:
* description: A list of all posts
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
public getAllPosts = catchAsync(async (req: AppRequest, res, _next) => {
const posts = await this.postRepository.find({
relations: {
createdBy: true,
likedBy: true,
comments: { createdBy: true },
},
});
// Remove sensitive fields
posts.forEach((post) => {
post.deleteSensitiveFields();
});
// Remove private, non followed users
const filteredPosts = posts.filter((post) => {
let followStatus = false;
// Get if the req user is in the follower list
if (post.createdBy.followers) {
followStatus = post.createdBy.followers.some(
(follower) => follower.id === req.user.id
);
}
return !(post.createdBy.isPrivate && !followStatus);
});
res.status(200).send(filteredPosts);
});
/**
* @swagger
* /posts/{id}:
* get:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: getPost
* summary: Get a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* responses:
* 200:
* description: A post
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
* 400:
* description: Invalid ID
* 404:
* description: No post found with that ID
*/
public getPost = catchAsync(async (req, res, next) => {
const { post, errorMessage } = await this.validateRequestAndGetEntities(
req
);
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID") {
return next(new AppError("No post found with that ID", 404));
}
// Remove sensitive fields
post.deleteSensitiveFields();
res.send(post);
});
/**
* @swagger
* /posts/followed:
* get:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: getFollowedPosts
* summary: Get posts of followed users
* responses:
* 200:
* description: A list of posts
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/Post'
*/
public getFollowedPosts = catchAsync(async (req: AppRequest, res, _next) => {
const user = await this.userRepository.findOne({
where: { id: req.user.id },
relations: {
followed: { posts: { likedBy: true, comments: true } },
posts: {
likedBy: true,
comments: true,
createdBy: true,
},
},
});
// Get all posts from the followed users
const followedPosts = user.followed
.map((followedUser) => {
followedUser.deleteSensitiveFields();
if (followedUser.posts) {
return followedUser.posts.map((post) => {
if (post.likedBy.length > 0) {
post.likedBy.forEach((user) => user.deleteSensitiveFields());
}
return {
...post,
createdBy: followedUser,
};
});
}
})
.flat();
res.send(followedPosts);
});
/**
* @swagger
* /posts:
* post:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: createPost
* summary: Create a post
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* description: Post title
* content:
* type: string
* description: Post content
* responses:
* 201:
* description: A post
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
* 400:
* description: Title and content are required
*/
public createPost = catchAsync(async (req: AppRequest, res, next) => {
const user = await this.userRepository.findOne({
where: { id: req.user.id },
});
const { title, content } = req.body;
if (!title || !content)
return next(new AppError("Title and content are required", 400));
const newPost = Object.assign(new Post(), {
title,
content,
createdBy: user,
createdAt: new Date(),
});
const post = await this.postRepository.save(newPost);
// Remove sensitive fields
post.deleteSensitiveFields();
res.status(201).send(post);
});
/**
* @swagger
* /posts/{id}:
* patch:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: updatePost
* summary: Update a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* title:
* type: string
* description: Post title
* content:
* type: string
* description: Post content
* responses:
* 200:
* description: A post
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
* 400:
* description: Invalid ID or Title and content are required
* 404:
* description: No post found with that ID
*/
public updatePost = catchAsync(async (req: AppRequest, res, next) => {
const { user, post, errorMessage } =
await this.validateRequestAndGetEntities(req);
const { title, content } = req.body;
if (!title || !content)
return next(new AppError("Title and content are required", 400));
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID") {
return next(new AppError("No post found with that ID", 404));
}
if (post.createdBy.id !== user.id) {
return next(
new AppError("You are not authorized to update this post", 403)
);
}
post.title = title;
post.content = content;
await this.postRepository.save(post);
// Remove sensitive fields
post.deleteSensitiveFields();
res.status(200).send(post);
});
/**
* @swagger
* /posts/{id}:
* delete:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: deletePost
* summary: Delete a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* responses:
* 204:
* description: No content
* 400:
* description: Invalid ID
* 404:
* description: No post found with that ID
*/
public deletePost = catchAsync(async (req: AppRequest, res, next) => {
const { user, post, errorMessage } =
await this.validateRequestAndGetEntities(req);
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID")
return next(new AppError("No post found with that ID", 404));
if (post.createdBy.id !== user.id) {
return next(
new AppError("You are not authorized to delete this post", 403)
);
}
await this.postRepository.remove(post);
res.status(204).send();
});
/**
* @swagger
* /posts/{id}/like:
* post:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: likePost
* summary: Like a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* responses:
* 200:
* description: A post
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
* 400:
* description: Invalid ID or You have already liked this post
* 404:
* description: No post found with that ID
*/
public likePost = catchAsync(async (req: AppRequest, res, next) => {
const { user, post, errorMessage } =
await this.validateRequestAndGetEntities(req);
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID") {
return next(new AppError("No post found with that ID", 404));
}
// Check if user has already liked the post
if (post.likedBy.some((likedUser) => likedUser.id === user.id)) {
return next(new AppError("You have already liked this post", 400));
}
post.likedBy.push(user);
await this.postRepository.save(post);
// Send notification to post creator
const newNotification = Object.assign(new Notification(), {
message: `${user.firstName} ${user.lastName} liked your post`,
belongsTo: post.createdBy,
timeStamp: new Date(),
});
const notification = await this.notificationRepository.save(
newNotification
);
post.createdBy.notifications.push(notification);
await this.userRepository.save(post.createdBy);
// Remove sensitive fields
post.deleteSensitiveFields();
res.status(200).send(post);
});
/**
* @swagger
* /posts/{id}/like:
* delete:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: unlikePost
* summary: Unlike a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* responses:
* 200:
* description: A post
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Post'
* 400:
* description: Invalid ID or You have not liked this post
* 404:
* description: No post found with that ID
*/
public unlikePost = catchAsync(async (req: AppRequest, res, next) => {
const { user, post, errorMessage } =
await this.validateRequestAndGetEntities(req);
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID")
return next(new AppError("No post found with that ID", 404));
// Check if user likes the post
if (!post.likedBy.some((likedUser) => likedUser.id === user.id)) {
return next(new AppError("You have not liked this post", 400));
}
post.likedBy = post.likedBy.filter((likedUser) => likedUser.id !== user.id);
await this.postRepository.save(post);
// Remove sensitive fields
post.deleteSensitiveFields();
res.status(200).send(post);
});
/**
* @swagger
* /posts/{id}/comment:
* post:
* security:
* - bearerAuth: []
* tags:
* - Posts
* operationId: commentPost
* summary: Comment on a post by ID
* parameters:
* - in: path
* name: id
* required: true
* description: ID of the post
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* content:
* type: string
* description: Comment content
* responses:
* 201:
* description: A comment
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Comment'
* 400:
* description: Invalid Request
* 404:
* description: No post found with that ID
*/
public commentPost = catchAsync(async (req: AppRequest, res, next) => {
const { user, post, errorMessage } =
await this.validateRequestAndGetEntities(req);
if (errorMessage == "Invalid ID")
return next(new AppError("Invalid ID", 400));
if (errorMessage == "No post found with that ID") {
return next(new AppError("No post found with that ID", 404));
}
const { content } = req.body;
const newComment = Object.assign(new Comment(), {
content,
createdBy: user,
post,
createdAt: new Date(),
});
const comment = await this.commentRepository.save(newComment);
// Send notification to post creator
const newNotification = Object.assign(new Notification(), {
message: `${user.firstName} ${user.lastName} commented on your post`,
belongsTo: post.createdBy,
timeStamp: new Date(),
});
await this.notificationRepository.save(newNotification);
// Remove sensitive fields
comment.createdBy.deleteSensitiveFields();
post.deleteSensitiveFields();
res.status(201).send(comment);
});
private validateRequestAndGetEntities = async (
req: AppRequest
): Promise<validatedEntities> => {
const postId = req.params.id;
const parsedId = parseInt(postId);
let errorMessage: "Invalid ID" | "No post found with that ID";
// Check if ID is a number
if (isNaN(parsedId)) errorMessage = "Invalid ID";
const user = req.user;
const post = await this.postRepository.findOne({
where: { id: parsedId },
relations: {
likedBy: true,
comments: { createdBy: true },
createdBy: { notifications: true },
},
});
// Check if post exists
if (!post) errorMessage = "No post found with that ID";
return { user, post, errorMessage };
};
}
interface validatedEntities {
user: User
post: Post
errorMessage?: 'Invalid ID' | 'No post found with that ID'
}

View File

@ -3,6 +3,7 @@ import { DataSource } from "typeorm"
import { User } from "./entity/User" import { User } from "./entity/User"
import {Comment} from "./entity/Comment"; import {Comment} from "./entity/Comment";
import {Post} from "./entity/Post"; import {Post} from "./entity/Post";
import {Notification} from "./entity/Notification";
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
type: "mysql", type: "mysql",
@ -13,7 +14,7 @@ export const AppDataSource = new DataSource({
database: process.env.MYSQL_DATABASE, database: process.env.MYSQL_DATABASE,
synchronize: true, synchronize: true,
logging: false, logging: false,
entities: [User, Comment, Post], entities: [User, Comment, Post, Notification],
migrations: [], migrations: [],
subscribers: [], subscribers: [],
}) })

View File

@ -1,5 +1,6 @@
import {Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn} from "typeorm"; import {Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn} from "typeorm";
import {User} from "./User"; import {User} from "./User";
import {Post} from "./Post";
@Entity() @Entity()
export class Comment { export class Comment {
@ -12,9 +13,12 @@ export class Comment {
@Column() @Column()
createdAt: Date createdAt: Date
@ManyToOne(() => User, user => user.posts) @ManyToOne(() => User, user => user.comments)
createdBy: User createdBy: User
@ManyToOne(() => Post, post => post.comments)
post: Post
@ManyToMany(()=> User) @ManyToMany(()=> User)
@JoinTable() @JoinTable()
likedBy: User[] likedBy: User[]

View File

@ -0,0 +1,21 @@
import {Column, Entity, ManyToOne, PrimaryGeneratedColumn} from "typeorm";
import {User} from "./User";
@Entity()
export class Notification {
@PrimaryGeneratedColumn()
id: number
@Column()
message: string
@Column()
timeStamp: Date
@Column({default: false})
seen: boolean
@ManyToOne(type => User, user => user.notifications)
belongsTo: User
}

View File

@ -1,5 +1,6 @@
import {Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn} from "typeorm"; import {Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn} from "typeorm";
import {User} from "./User"; import {User} from "./User";
import {Comment} from "./Comment";
@Entity() @Entity()
export class Post { export class Post {
@ -18,8 +19,28 @@ export class Post {
@ManyToOne(() => User, user => user.posts) @ManyToOne(() => User, user => user.posts)
createdBy: User createdBy: User
@OneToMany(() => Comment, comment => comment.post)
comments: Comment[]
@ManyToMany(()=> User) @ManyToMany(()=> User)
@JoinTable() @JoinTable()
likedBy: User[] likedBy: User[]
public deleteSensitiveFields(){
this.createdBy.deleteSensitiveFields()
if (this.likedBy) {
if (this.likedBy.length > 0) {
this.likedBy.forEach((user) => user.deleteSensitiveFields());
}
}
if (this.comments) {
if (this.comments.length > 0) {
this.comments.forEach((comment) =>
comment.createdBy.deleteSensitiveFields()
);
}
}
}
} }

View File

@ -2,41 +2,67 @@ import {Entity, PrimaryGeneratedColumn, Column, ManyToMany, OneToMany, JoinTable
import * as bcrypt from "bcrypt" import * as bcrypt from "bcrypt"
import {Post} from "./Post"; import {Post} from "./Post";
import {Comment} from "./Comment"; import {Comment} from "./Comment";
import {Notification} from "./Notification";
@Entity() @Entity()
export class User { export class User {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number id: number;
@Column() @Column()
firstName: string firstName: string;
@Column() @Column()
lastName: string lastName: string;
@Column() @Column()
email: string email: string;
@Column() @Column()
password: string password: string;
@ManyToMany(type => User) @Column({ default: false })
isPrivate: boolean;
@Column({ default: null })
profilePictureId: string;
@ManyToMany((type) => User)
@JoinTable() @JoinTable()
followed: User[] followed: User[];
@OneToMany(type => Post, post=> post.createdBy) @ManyToMany((type) => User)
posts: Post[] @JoinTable()
followers: User[];
@OneToMany(type => Comment, comment=> comment.createdBy) @OneToMany((type) => Post, (post) => post.createdBy)
comments: Comment[] posts: Post[];
@OneToMany((type) => Comment, (comment) => comment.createdBy)
comments: Comment[];
@OneToMany((type) => Notification, (notification) => notification.belongsTo)
notifications: Notification[];
static async hashPassword(password: string) { static async hashPassword(password: string) {
return await bcrypt.hash(password, parseInt(process.env.SALT_ROUNDS)) return await bcrypt.hash(password, parseInt(process.env.SALT_ROUNDS));
} }
async comparePassword(password: string) { async comparePassword(password: string) {
return await bcrypt.compare(password, this.password) return await bcrypt.compare(password, this.password);
}
public deleteSensitiveFields() {
this.deleteExtraFields();
this.deletePrivateFields();
}
public deleteExtraFields() {
delete this.followed;
delete this.followers;
}
public deletePrivateFields(){
delete this.email;
delete this.password;
delete this.notifications;
} }
} }

View File

@ -3,24 +3,40 @@ import * as bodyParser from "body-parser"
import { AppDataSource } from "./data-source" import { AppDataSource } from "./data-source"
import {errorHandler} from "./controller/errorController"; import {errorHandler} from "./controller/errorController";
import {AuthRoutes} from "./routes/authRoutes"; import {AuthRoutes} from "./routes/authRoutes";
import {UserRoutes} from "./routes/userRoutes";
import {AuthController} from "./controller/authController";
import {PostRoutes} from "./routes/postRoutes";
import {swaggerRouter} from "./routes/swaggerRoutes";
import * as cors from "cors";
AppDataSource.initialize().then(async () => { AppDataSource.initialize()
.then(async () => {
// create express app // create express app
const app = express() const app = express();
app.use(bodyParser.json()) app.use(bodyParser.json());
app.use(cors());
// register express routes from defined application routes // register express routes from defined application routes
// Auth Routes // Auth Routes
app.use('/auth', AuthRoutes) app.use("/auth", AuthRoutes);
// Swagger Routes
app.use("/docs", swaggerRouter);
// All routes after this one require authentication
const authController = new AuthController();
app.use(authController.protect);
app.use("/users", UserRoutes);
app.use("/posts", PostRoutes);
// setup express app here // setup express app here
app.use(errorHandler) app.use(errorHandler);
// start express server // start express server
app.listen(3000) app.listen(3000);
console.log(
"Express server has started on port 3000. Open http://localhost:3000/users to see results"
console.log("Express server has started on port 3000. Open http://localhost:3000/users to see results") );
})
}).catch(error => console.log(error)) .catch((error) => console.log(error));

View File

@ -7,6 +7,7 @@ export const AuthRoutes = Router();
AuthRoutes.route("/signup").post(authController.handleSignUp) AuthRoutes.route("/signup").post(authController.handleSignUp)
AuthRoutes.route("/login").post(authController.handleLogin) AuthRoutes.route("/login").post(authController.handleLogin)
AuthRoutes.route("/logout").get(authController.handleLogout)

View File

@ -0,0 +1,19 @@
import {PostController} from "../controller/postController";
import {Router} from "express";
const postController = new PostController()
export const PostRoutes = Router();
PostRoutes.route("/")
.get(postController.getAllPosts)
.post(postController.createPost)
PostRoutes.route("/followed").get(postController.getFollowedPosts)
PostRoutes.route("/:id")
.get(postController.getPost)
.patch(postController.updatePost)
.delete(postController.deletePost)
PostRoutes.route("/:id/like")
.post(postController.likePost)
.delete(postController.unlikePost)
PostRoutes.route("/:id/comment").post(postController.commentPost)

View File

@ -0,0 +1,202 @@
import * as swaggerJSdoc from "swagger-jsdoc";
import {Router} from "express";
import * as swaggerUi from "swagger-ui-express";
import { catchAsync } from "../util/catchAsync";
const swaggerOptions = {
swaggerDefinition: {
openapi: "3.0.0",
info: {
title: "DevSpace API",
description: "API for DevSpace",
version: "0.1.0",
},
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
schemas: {
Post: {
type: "object",
properties: {
id: {
type: "integer",
description: "Post ID",
},
title: {
type: "string",
description: "Post title",
},
content: {
type: "string",
description: "Post content",
},
createdAt: {
type: "string",
description: "Post creation date",
},
createdBy: {
$ref: "#/components/schemas/User",
},
likedBy: {
type: "array",
items: {
$ref: "#/components/schemas/User",
},
},
comments: {
type: "array",
items: {
$ref: "#/components/schemas/Comment",
},
},
},
},
Comment: {
type: "object",
properties: {
id: {
type: "integer",
description: "Comment ID",
},
content: {
type: "string",
description: "Comment content",
},
createdAt: {
type: "string",
description: "Comment creation date",
},
createdBy: {
$ref: "#/components/schemas/User",
},
likedBy: {
type: "array",
items: {
$ref: "#/components/schemas/User",
},
},
},
},
Notification: {
type: "object",
properties: {
id: {
type: "integer",
description: "Notification ID",
},
message: {
type: "string",
description: "Notification message",
},
timeStamp: {
type: "string",
description: "Notification creation date",
},
seen: {
type: "boolean",
description: "Notification seen status",
},
},
},
User: {
type: "object",
properties: {
id: {
type: "integer",
description: "User ID",
},
firstName: {
type: "string",
description: "User first name",
},
isPrivate: {
type: "boolean",
description: "User private status",
},
profilePictureId: {
type: "string",
description: "User profile picture ID",
},
lastName: {
type: "string",
description: "User last name",
},
},
},
UserWithRelations: {
allOf: [
{
$ref: "#/components/schemas/User",
},
{
type: "object",
properties: {
posts: {
type: "array",
items: {
$ref: "#/components/schemas/Post",
},
},
comments: {
type: "array",
items: {
$ref: "#/components/schemas/Comment", // Assuming you have a Comment schema
},
},
followed: {
type: "array",
items: {
$ref: "#/components/schemas/User",
},
},
followers: {
type: "array",
items: {
$ref: "#/components/schemas/User",
},
},
},
},
],
},
UserWithRelationsAndNotifications: {
allOf: [
{ $ref: "#/components/schemas/UserWithRelations" },
{
type: "object",
properties: {
notifications: {
type: "array",
items: {
$ref: "#/components/schemas/Notification",
},
},
},
},
],
},
},
},
},
apis: ["**/controller/*.ts"],
};
const swaggerDocs = swaggerJSdoc(swaggerOptions);
console.log(swaggerDocs)
export const swaggerRouter = Router()
swaggerRouter.get(
"/json", catchAsync(async (req, res, next) => {
res.json(swaggerDocs);
})
);
swaggerRouter.use("/", swaggerUi.serve, swaggerUi.setup(swaggerDocs));

View File

@ -0,0 +1,15 @@
import {Router} from "express";
import {UserController} from "../controller/UserController";
export const UserRoutes = Router();
const userController = new UserController()
UserRoutes.route("/").get(userController.getAllUsers)
UserRoutes.route("/me")
.get(userController.getMe)
.patch(userController.updateMe);
UserRoutes.route("/:id").get(userController.getUser)
UserRoutes.route("/follow/:id")
.post(userController.followUser)
.delete(userController.unfollowUser)