chore: initial commit
This commit is contained in:
commit
714cc643ba
1
.commitlintrc.json
Normal file
1
.commitlintrc.json
Normal file
@ -0,0 +1 @@
|
||||
{ "extends": ["@commitlint/config-conventional"] }
|
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
||||
.vscode
|
||||
.git
|
||||
build
|
||||
coverage
|
||||
node_modules
|
||||
tmp
|
||||
temp
|
||||
**/__test__/**
|
11
.editorconfig
Normal file
11
.editorconfig
Normal file
@ -0,0 +1,11 @@
|
||||
# For more information see: https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
COMPOSE_PROJECT_NAME=thream-api
|
||||
PORT=8080
|
||||
API_BASE_URL=http://localhost:8080
|
||||
DATABASE_DIALECT=mysql
|
||||
DATABASE_HOST=thream-database
|
||||
DATABASE_NAME=thream
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=password
|
||||
DATABASE_PORT=3306
|
||||
JWT_ACCESS_EXPIRES_IN=15 minutes
|
||||
JWT_ACCESS_SECRET=accessTokenSecret
|
||||
JWT_REFRESH_SECRET=refreshTokenSecret
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
EMAIL_HOST=thream-maildev
|
||||
EMAIL_USER=no-reply@thream.fr
|
||||
EMAIL_PASSWORD=password
|
||||
EMAIL_PORT=25
|
20
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/BUG.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '🐛 Bug Report'
|
||||
about: 'Report an unexpected problem or unintended behavior.'
|
||||
title: '[Bug]'
|
||||
labels: 'bug'
|
||||
---
|
||||
|
||||
<!--
|
||||
Please provide a clear and concise description of what the bug is. Include
|
||||
screenshots if needed. Please make sure your issue has not already been fixed.
|
||||
-->
|
||||
|
||||
## Steps To Reproduce
|
||||
|
||||
1. Step 1
|
||||
2. Step 2
|
||||
|
||||
## The current behavior
|
||||
|
||||
## The expected behavior
|
18
.github/ISSUE_TEMPLATE/DOCUMENTATION.md
vendored
Normal file
18
.github/ISSUE_TEMPLATE/DOCUMENTATION.md
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: '📜 Documentation'
|
||||
about: 'Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).'
|
||||
title: '[Documentation]'
|
||||
labels: 'documentation'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Documentation
|
||||
|
||||
<!-- Please uncomment the type of documentation problem this issue address -->
|
||||
|
||||
<!-- Documentation is Missing -->
|
||||
<!-- Documentation is Confusing -->
|
||||
<!-- Documentation has Typo errors -->
|
||||
|
||||
## Proposal
|
20
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '✨ Feature Request'
|
||||
about: 'Suggest a new feature idea.'
|
||||
title: '[Feature]'
|
||||
labels: 'feature request'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem or missing capability... -->
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
<!-- If you have a solution in mind, please describe it. -->
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
<!-- Have you considered any alternative solutions or workarounds? -->
|
20
.github/ISSUE_TEMPLATE/IMPROVEMENT.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/IMPROVEMENT.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: '🔧 Improvement'
|
||||
about: 'Improve structure/format/performance/refactor/tests of the code.'
|
||||
title: '[Improvement]'
|
||||
labels: 'improvement'
|
||||
---
|
||||
|
||||
<!-- Please make sure your issue has not already been fixed. -->
|
||||
|
||||
## Type of Improvement
|
||||
|
||||
<!-- Please uncomment the type of improvements this issue address -->
|
||||
|
||||
<!-- Files and Folders Structure -->
|
||||
<!-- Performance -->
|
||||
<!-- Refactoring code -->
|
||||
<!-- Tests -->
|
||||
<!-- Not Sure? -->
|
||||
|
||||
## Proposal
|
8
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
8
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: '🙋 Question'
|
||||
about: 'Further information is requested.'
|
||||
title: '[Question]'
|
||||
labels: 'question'
|
||||
---
|
||||
|
||||
### Question
|
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
<!-- Please first discuss the change you wish to make via issue before making a change. It might avoid a waste of your time. -->
|
||||
|
||||
## What changes this PR introduce?
|
||||
|
||||
## List any relevant issue numbers
|
||||
|
||||
## Is there anything you'd like reviewers to focus on?
|
27
.github/workflows/analyze.yml
vendored
Normal file
27
.github/workflows/analyze.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: 'Analyze'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: 'ubuntu-latest'
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript']
|
||||
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2.3.4'
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: 'github/codeql-action/init@v1'
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: 'Perform CodeQL Analysis'
|
||||
uses: 'github/codeql-action/analyze@v1'
|
24
.github/workflows/build.yml
vendored
Normal file
24
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: 'Build'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.1.5'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm ci --cache .npm --prefer-offline'
|
||||
|
||||
- name: 'Build'
|
||||
run: 'npm run build'
|
27
.github/workflows/lint.yml
vendored
Normal file
27
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: 'Lint'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.1.5'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm ci --cache .npm --prefer-offline'
|
||||
|
||||
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
|
||||
- run: 'npm run lint:editorconfig'
|
||||
- run: 'npm run lint:markdown'
|
||||
- run: 'npm run lint:docker'
|
||||
- run: 'npm run lint:typescript'
|
24
.github/workflows/test.yml
vendored
Normal file
24
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
name: 'Test'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, develop]
|
||||
pull_request:
|
||||
branches: [master, develop]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v2'
|
||||
|
||||
- name: 'Use Node.js'
|
||||
uses: 'actions/setup-node@v2.1.5'
|
||||
with:
|
||||
node-version: '16.x'
|
||||
|
||||
- name: 'Install'
|
||||
run: 'npm ci --cache .npm --prefer-offline'
|
||||
|
||||
- name: 'Test'
|
||||
run: 'npm run test'
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.npm
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# envs
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
# editors
|
||||
.vscode
|
||||
.theia
|
||||
.idea
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
tmp
|
||||
temp
|
1
.husky/.gitignore
vendored
Normal file
1
.husky/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
_
|
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint:commit -- --edit
|
8
.husky/pre-commit
Executable file
8
.husky/pre-commit
Executable file
@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npm run lint:docker
|
||||
npm run lint:editorconfig
|
||||
npm run lint:markdown
|
||||
npm run lint:typescript
|
||||
npm run build
|
7
.markdownlint.json
Normal file
7
.markdownlint.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"default": true,
|
||||
"MD013": false,
|
||||
"MD024": false,
|
||||
"MD033": false,
|
||||
"MD041": false
|
||||
}
|
123
CONTRIBUTING.md
Normal file
123
CONTRIBUTING.md
Normal file
@ -0,0 +1,123 @@
|
||||
# 💡 Contributing
|
||||
|
||||
Thanks a lot for your interest in contributing to **Thream/api**! 🎉
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
**Thream** has adopted the [Contributor Covenant](https://www.contributor-covenant.org/) as its Code of Conduct, and we expect project participants to adhere to it. Please read [the full text](https://github.com/Thream/Thream/blob/master/.github/CODE_OF_CONDUCT.md) so that you can understand what actions will and will not be tolerated.
|
||||
|
||||
## Open Development
|
||||
|
||||
All work on **Thream/api** happens directly on [GitHub](https://github.com/Thream). Both core team members and external contributors send pull requests which go through the same review process.
|
||||
|
||||
## Types of contributions
|
||||
|
||||
- Reporting a bug.
|
||||
- Suggest a new feature idea.
|
||||
- Correct spelling errors, improvements or additions to documentation files (README, CONTRIBUTING...).
|
||||
- Improve structure/format/performance/refactor/tests of the code.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- **Please first discuss** the change you wish to make via [issue](https://github.com/Thream/api/issues) before making a change. It might avoid a waste of your time.
|
||||
|
||||
- Ensure your code respect [Typescript Standard Style](https://www.npmjs.com/package/ts-standard).
|
||||
|
||||
- Make sure your **code passes the tests**.
|
||||
|
||||
If you're adding new features to **Thream/api**, please include tests.
|
||||
|
||||
## Commits
|
||||
|
||||
The commit message guidelines respect [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional) and [Semantic Versioning](https://semver.org/) for releases.
|
||||
|
||||
### Types
|
||||
|
||||
Types define which kind of changes you made to the project.
|
||||
|
||||
| Types | Description |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| feat | A new feature. |
|
||||
| fix | A bug fix. |
|
||||
| docs | Documentation only changes. |
|
||||
| style | Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). |
|
||||
| refactor | A code change that neither fixes a bug nor adds a feature. |
|
||||
| perf | A code change that improves performance. |
|
||||
| test | Adding missing tests or correcting existing tests. |
|
||||
| build | Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm). |
|
||||
| ci | Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs). |
|
||||
| chore | Other changes that don't modify src or test files. |
|
||||
| revert | Reverts a previous commit. |
|
||||
|
||||
### Scopes
|
||||
|
||||
Scopes define what part of the code changed.
|
||||
|
||||
### Examples
|
||||
|
||||
```sh
|
||||
git commit -m "feat(users): add POST /users/signup"
|
||||
git commit -m "docs(readme): update installation process"
|
||||
git commit -m "fix(messages): should emit events to connected users"
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
├── email
|
||||
├── public
|
||||
├── scripts
|
||||
└── src
|
||||
├── models
|
||||
├── services
|
||||
├── tools
|
||||
└── typings
|
||||
```
|
||||
|
||||
### Each folder explained
|
||||
|
||||
- `email` : email template(s) and translation(s)
|
||||
- `src` : all source files
|
||||
- `models` : models that represent tables in database (there is a `_data.sql` file to have dummy data to work with in development mode)
|
||||
- `services` : all REST API endpoints
|
||||
- `tools` : configs and utilities
|
||||
- `typings` : types gloablly used in the project
|
||||
- `uploads` : uploaded files by users
|
||||
|
||||
### Services folder explained with an example
|
||||
|
||||
We have API REST services for the `channels`.
|
||||
|
||||
Here is what potentially look like a folder structure for this service :
|
||||
|
||||
```text
|
||||
└── src
|
||||
└── services
|
||||
└── channels
|
||||
├── __docs__
|
||||
│ └── get.yaml
|
||||
├── __test__
|
||||
│ └── get.test.ts
|
||||
├── [channelId]
|
||||
│ ├── __docs__
|
||||
│ │ ├── delete.yaml
|
||||
│ │ └── put.yaml
|
||||
│ ├── __test__
|
||||
│ │ ├── delete.test.ts
|
||||
│ │ └── put.test.ts
|
||||
│ ├── delete.ts
|
||||
│ ├── index.ts
|
||||
│ └── put.ts
|
||||
├── get.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
This folder structure will map to these REST API routes :
|
||||
|
||||
- GET `/channels`
|
||||
- DELETE `/channels/:channelId`
|
||||
- PUT `/channels/:channelId`
|
||||
|
||||
The folders after `src/services` : is the real path of the routes in the API except folders starting and ending with `__` like `__docs__`, `__test__` or `__utils__`.
|
||||
|
||||
The filenames correspond to the HTTP methods used (`get`, `post`, `put`, `delete`).
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@ -0,0 +1,11 @@
|
||||
FROM node:14.16.1
|
||||
RUN npm install --global npm@7
|
||||
|
||||
WORKDIR /api
|
||||
|
||||
COPY ./package*.json ./
|
||||
RUN npm install
|
||||
COPY ./ ./
|
||||
RUN npm run build
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
27
Dockerfile.production
Normal file
27
Dockerfile.production
Normal file
@ -0,0 +1,27 @@
|
||||
ARG NODE_VERSION=14.16.1
|
||||
|
||||
FROM node:${NODE_VERSION} AS dependencies
|
||||
RUN npm install --global npm@7
|
||||
WORKDIR /api
|
||||
COPY ./package*.json ./
|
||||
RUN npm clean-install
|
||||
|
||||
FROM node:${NODE_VERSION} AS builder
|
||||
WORKDIR /api
|
||||
COPY ./ ./
|
||||
COPY --from=dependencies /api/node_modules ./node_modules
|
||||
RUN npm run build
|
||||
|
||||
FROM node:${NODE_VERSION} AS runner
|
||||
WORKDIR /api
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /api/node_modules ./node_modules
|
||||
COPY --from=builder /api/build ./build
|
||||
COPY --from=builder /api/email ./email
|
||||
COPY --from=builder /api/uploads ./uploads
|
||||
|
||||
RUN chown --recursive node /api/build
|
||||
USER node
|
||||
|
||||
CMD ["node", "build/index.js"]
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Thream
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
86
README.md
Normal file
86
README.md
Normal file
@ -0,0 +1,86 @@
|
||||
<h1 align="center"><a href="https://api.thream.divlo.fr/docs">Thream/api</a></h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Thream's application programming interface to stay close with your friends and communities.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" /></a>
|
||||
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
|
||||
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
|
||||
<br />
|
||||
<a href="https://github.com/Thream/api/actions/workflows/analyze.yml"><img src="https://github.com/Thream/api/actions/workflows/analyze.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/api/actions/workflows/build.yml"><img src="https://github.com/Thream/api/actions/workflows/build.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/api/actions/workflows/lint.yml"><img src="https://github.com/Thream/api/actions/workflows/lint.yml/badge.svg?branch=develop" /></a>
|
||||
<a href="https://github.com/Thream/api/actions/workflows/test.yml"><img src="https://github.com/Thream/api/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
|
||||
<br />
|
||||
<a href="https://www.npmjs.com/package/ts-standard"><img alt="TypeScript Standard Style" src="https://camo.githubusercontent.com/f87caadb70f384c0361ec72ccf07714ef69a5c0a/68747470733a2f2f62616467656e2e6e65742f62616467652f636f64652532307374796c652f74732d7374616e646172642f626c75653f69636f6e3d74797065736372697074"/></a>
|
||||
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
|
||||
<a href="https://dependabot.com/"><img src="https://badgen.net/github/dependabot/Thream/api?icon=dependabot" alt="Dependabot badge" /></a>
|
||||
</p>
|
||||
|
||||
## 📜 About
|
||||
|
||||
Thream's application programming interface to stay close with your friends and communities.
|
||||
|
||||
This project was bootstrapped with [create-fullstack-app](https://github.com/Divlo/create-fullstack-app).
|
||||
|
||||
## ⚙️ Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 14
|
||||
- [npm](https://www.npmjs.com/) >= 6
|
||||
- [MySQL](https://www.mysql.com/) >= 8
|
||||
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# Clone the repository
|
||||
git clone https://github.com/Thream/api.git
|
||||
|
||||
# Go to the project root
|
||||
cd api
|
||||
|
||||
# Configure environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
You will need to configure the environment variables by creating an `.env` file at the root of the project (see `.env.example`).
|
||||
|
||||
### Development environment with [Docker](https://www.docker.com/)
|
||||
|
||||
```sh
|
||||
# Setup and run all the services for you
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
### Production environment with [Docker](https://www.docker.com/)
|
||||
|
||||
```sh
|
||||
# Setup and run all the services for you
|
||||
docker-compose --file=docker-compose.production.yml up
|
||||
```
|
||||
|
||||
#### Services started
|
||||
|
||||
- API : `http://localhost:8080`
|
||||
- [MySQL database](https://www.mysql.com/)
|
||||
|
||||
#### Services started only in Development environment
|
||||
|
||||
- [phpmyadmin](https://www.phpmyadmin.net/) : `http://localhost:8000`
|
||||
- [MailDev](https://maildev.github.io/maildev/) : `http://localhost:1080`
|
||||
|
||||
## 💡 Contributing
|
||||
|
||||
Anyone can help to improve the project, submit a Feature Request, a bug report or even correct a simple spelling mistake.
|
||||
|
||||
The steps to contribute can be found in the [CONTRIBUTING.md](./CONTRIBUTING.md) file.
|
||||
|
||||
## 📄 License
|
||||
|
||||
[MIT](./LICENSE)
|
34
docker-compose.production.yml
Normal file
34
docker-compose.production.yml
Normal file
@ -0,0 +1,34 @@
|
||||
version: '3.0'
|
||||
services:
|
||||
thream-api:
|
||||
container_name: ${COMPOSE_PROJECT_NAME}
|
||||
build:
|
||||
context: './'
|
||||
dockerfile: './Dockerfile.production'
|
||||
environment:
|
||||
PORT: ${PORT}
|
||||
env_file: './.env'
|
||||
ports:
|
||||
- '${PORT}:${PORT}'
|
||||
depends_on:
|
||||
- ${DATABASE_HOST}
|
||||
volumes:
|
||||
- './uploads:/api/uploads'
|
||||
restart: 'unless-stopped'
|
||||
|
||||
thream-database:
|
||||
container_name: ${DATABASE_HOST}
|
||||
image: 'mysql:8.0.23'
|
||||
command: '--default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
|
||||
MYSQL_DATABASE: ${DATABASE_NAME}
|
||||
MYSQL_TCP_PORT: ${DATABASE_PORT}
|
||||
ports:
|
||||
- '${DATABASE_PORT}:${DATABASE_PORT}'
|
||||
volumes:
|
||||
- 'database-volume:/var/lib/mysql'
|
||||
restart: 'unless-stopped'
|
||||
|
||||
volumes:
|
||||
database-volume:
|
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@ -0,0 +1,52 @@
|
||||
version: '3.0'
|
||||
services:
|
||||
thream-api:
|
||||
container_name: ${COMPOSE_PROJECT_NAME}
|
||||
build:
|
||||
context: './'
|
||||
ports:
|
||||
- '${PORT}:${PORT}'
|
||||
depends_on:
|
||||
- ${DATABASE_HOST}
|
||||
- 'thream-maildev'
|
||||
volumes:
|
||||
- './:/api'
|
||||
restart: 'unless-stopped'
|
||||
|
||||
thream-phpmyadmin:
|
||||
container_name: 'thream-phpmyadmin'
|
||||
image: 'phpmyadmin/phpmyadmin:5.0.4'
|
||||
environment:
|
||||
PMA_HOST: ${DATABASE_HOST}
|
||||
PMA_PORT: ${DATABASE_PORT}
|
||||
PMA_USER: ${DATABASE_USER}
|
||||
PMA_PASSWORD: ${DATABASE_PASSWORD}
|
||||
ports:
|
||||
- '8000:80'
|
||||
depends_on:
|
||||
- ${DATABASE_HOST}
|
||||
restart: 'unless-stopped'
|
||||
|
||||
thream-database:
|
||||
container_name: ${DATABASE_HOST}
|
||||
image: 'mysql:8.0.23'
|
||||
command: '--default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD}
|
||||
MYSQL_DATABASE: ${DATABASE_NAME}
|
||||
MYSQL_TCP_PORT: ${DATABASE_PORT}
|
||||
ports:
|
||||
- '${DATABASE_PORT}:${DATABASE_PORT}'
|
||||
volumes:
|
||||
- 'database-volume:/var/lib/mysql'
|
||||
restart: 'unless-stopped'
|
||||
|
||||
thream-maildev:
|
||||
container_name: 'thream-maildev'
|
||||
image: 'maildev/maildev:1.1.0'
|
||||
ports:
|
||||
- '1080:80'
|
||||
restart: 'unless-stopped'
|
||||
|
||||
volumes:
|
||||
database-volume:
|
148
email/email-template.ejs
Normal file
148
email/email-template.ejs
Normal file
@ -0,0 +1,148 @@
|
||||
<center>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="20"
|
||||
cellspacing="0"
|
||||
height="100%"
|
||||
width="100%"
|
||||
style="background-color: <%= theme.backgroundPrimary %>"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
width="100%"
|
||||
style="max-width: 600px; border-radius: 6px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
width="100%"
|
||||
style="max-width: 600px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<h1
|
||||
style="
|
||||
font-family: Poppins, Arial, Helvetica, sans-serif;
|
||||
color: <%= theme.colorSecondary %>;
|
||||
font-size: 28px;
|
||||
line-height: 110%;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
"
|
||||
>
|
||||
Thream
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
width="100%"
|
||||
style="max-width: 600px; border-radius: 6px"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
valign="top"
|
||||
style="
|
||||
line-height: 150%;
|
||||
font-family: Helvetica;
|
||||
font-size: 14px;
|
||||
color: rgb(222, 222, 222);
|
||||
padding: 30px;
|
||||
box-shadow: 0px 0px 6px 6px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid black;
|
||||
border-radius: 1rem;
|
||||
"
|
||||
>
|
||||
<h2
|
||||
style="
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
margin: 0 0 12px 0;
|
||||
color: <%= theme.colorSecondary %>;
|
||||
"
|
||||
>
|
||||
<%= text.subtitle %>
|
||||
</h2>
|
||||
<a
|
||||
href="<%= text.url %>"
|
||||
style="
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 42px;
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
height: 42px;
|
||||
margin: 12px 5px 12px 0;
|
||||
padding: 0 22px;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
vertical-align: top;
|
||||
background-color: <%= theme.backgroundPrimary %>;
|
||||
border: 1px solid <%= theme.colorPrimary %>;
|
||||
"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
><span
|
||||
style="
|
||||
display: inline;
|
||||
font-family: 'Helvetica', Arial, sans-serif;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-size: 16px;
|
||||
line-height: 42px;
|
||||
border: none;
|
||||
color: <%= theme.colorPrimary %>;
|
||||
"
|
||||
><%= text.button %></span
|
||||
></a
|
||||
>
|
||||
<br />
|
||||
<div>
|
||||
<p
|
||||
style="
|
||||
padding: 0 0 10px 0;
|
||||
color: <%= theme.colorSecondary %>;
|
||||
"
|
||||
>
|
||||
<%= text.footer %>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</center>
|
8
email/locales/en/confirm-email.json
Normal file
8
email/locales/en/confirm-email.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Confirm email",
|
||||
"renderOptions": {
|
||||
"subtitle": "Please confirm your email to signin",
|
||||
"button": "Yes, I confirm",
|
||||
"footer": "If you received this message by mistake, just delete it. Your email will not be confirmed if you do not click on the confirmation link above."
|
||||
}
|
||||
}
|
8
email/locales/en/reset-password.json
Normal file
8
email/locales/en/reset-password.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Reset password",
|
||||
"renderOptions": {
|
||||
"subtitle": "Please confirm password reset",
|
||||
"button": "Yes, I change my password",
|
||||
"footer": "If you received this message by mistake, just delete it. Your password will not be reset if you do not click on the link above. Also, for the security of your account, the password reset is available for a period of 1 hour, after this time, the reset will no longer be possible."
|
||||
}
|
||||
}
|
8
email/locales/fr/confirm-email.json
Normal file
8
email/locales/fr/confirm-email.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Confirmez votre email",
|
||||
"renderOptions": {
|
||||
"subtitle": "Veuillez confirmer votre adresse email pour vous connecter",
|
||||
"button": "Oui, je confirme",
|
||||
"footer": "Si vous avez reçu ce message par erreur, supprimez-le simplement. Votre email ne sera pas confirmé si vous ne cliquez pas sur le lien de confirmation ci-dessus."
|
||||
}
|
||||
}
|
8
email/locales/fr/reset-password.json
Normal file
8
email/locales/fr/reset-password.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"subject": "Réinitialiser le mot de passe",
|
||||
"renderOptions": {
|
||||
"subtitle": "Veuillez confirmer la réinitialisation du mot de passe",
|
||||
"button": "Oui, je change mon mot de passe",
|
||||
"footer": "Si vous avez reçu ce message par erreur, supprimez-le simplement. Votre mot de passe ne sera pas réinitialisé si vous ne cliquez pas sur le lien ci-dessus. Aussi, pour la sécurité de votre compte, la réinitialisation du mot de passe est disponible pour une durée de 1 heure, après ce temps, la réinitialisation ne sera plus possible."
|
||||
}
|
||||
}
|
29649
package-lock.json
generated
Normal file
29649
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
146
package.json
Normal file
146
package.json
Normal file
@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "@thream/api",
|
||||
"version": "0.0.0-development",
|
||||
"private": true,
|
||||
"release-it": {
|
||||
"git": {
|
||||
"commit": false,
|
||||
"push": false,
|
||||
"tag": false
|
||||
},
|
||||
"gitlab": {
|
||||
"release": false
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": [
|
||||
"npm run lint:docker",
|
||||
"npm run lint:editorconfig",
|
||||
"npm run lint:markdown",
|
||||
"npm run lint:typescript",
|
||||
"npm run build",
|
||||
"npm run test"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": "angular",
|
||||
"infile": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"setupFiles": [
|
||||
"./__test__/setEnvsVars.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./__test__/setup.ts"
|
||||
],
|
||||
"rootDir": "./src",
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": "../coverage/",
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"cobertura"
|
||||
]
|
||||
},
|
||||
"ts-standard": {
|
||||
"ignore": [
|
||||
"build",
|
||||
"coverage",
|
||||
"node_modules",
|
||||
"uploads"
|
||||
],
|
||||
"envs": [
|
||||
"node",
|
||||
"jest"
|
||||
],
|
||||
"report": "stylish"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rimraf ./build && tsc",
|
||||
"start": "cross-env NODE_ENV=production node build/index.js",
|
||||
"dev": "concurrently --kill-others --names \"TypeScript,Node\" --prefix \"[{name}]\" --prefix-colors \"blue,green\" \"tsc --watch\" \"cross-env NODE_ENV=development nodemon -e js,json,yaml build/index.js\"",
|
||||
"lint:commit": "commitlint",
|
||||
"lint:docker": "dockerfilelint './Dockerfile' && dockerfilelint './Dockerfile.production'",
|
||||
"lint:editorconfig": "editorconfig-checker",
|
||||
"lint:markdown": "markdownlint '**/*.md' --dot --ignore node_modules",
|
||||
"lint:typescript": "ts-standard",
|
||||
"release": "release-it",
|
||||
"test": "jest",
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@thream/socketio-jwt": "2.1.0",
|
||||
"axios": "0.21.1",
|
||||
"bcryptjs": "2.4.3",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "8.2.0",
|
||||
"ejs": "3.1.6",
|
||||
"express": "4.17.1",
|
||||
"express-async-errors": "3.1.1",
|
||||
"express-fileupload": "1.2.1",
|
||||
"express-rate-limit": "5.2.6",
|
||||
"express-validator": "6.10.0",
|
||||
"helmet": "4.5.0",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"morgan": "1.10.0",
|
||||
"ms": "2.1.3",
|
||||
"mysql2": "2.2.5",
|
||||
"nodemailer": "6.5.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"sequelize": "6.6.2",
|
||||
"sequelize-typescript": "2.1.0",
|
||||
"socket.io": "4.0.1",
|
||||
"swagger-jsdoc": "6.1.0",
|
||||
"swagger-ui-express": "4.1.6",
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "12.1.1",
|
||||
"@commitlint/config-conventional": "12.1.1",
|
||||
"@release-it/conventional-changelog": "2.0.1",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/cors": "2.8.10",
|
||||
"@types/ejs": "3.0.6",
|
||||
"@types/express": "4.17.11",
|
||||
"@types/express-fileupload": "1.1.6",
|
||||
"@types/express-rate-limit": "5.1.1",
|
||||
"@types/jest": "26.0.22",
|
||||
"@types/jsonwebtoken": "8.5.1",
|
||||
"@types/mock-fs": "4.13.0",
|
||||
"@types/morgan": "1.9.2",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/node": "14.14.41",
|
||||
"@types/nodemailer": "6.4.1",
|
||||
"@types/server-destroy": "1.0.1",
|
||||
"@types/supertest": "2.0.11",
|
||||
"@types/swagger-jsdoc": "6.0.0",
|
||||
"@types/swagger-ui-express": "4.1.2",
|
||||
"@types/uuid": "8.3.0",
|
||||
"@types/validator": "13.1.3",
|
||||
"concurrently": "6.0.2",
|
||||
"cross-env": "7.0.3",
|
||||
"dockerfilelint": "1.8.0",
|
||||
"editorconfig-checker": "4.0.2",
|
||||
"husky": "6.0.0",
|
||||
"jest": "26.6.3",
|
||||
"markdownlint-cli": "0.27.1",
|
||||
"mock-fs": "4.13.0",
|
||||
"nodemon": "2.0.7",
|
||||
"release-it": "14.6.1",
|
||||
"rimraf": "3.0.2",
|
||||
"server-destroy": "1.0.1",
|
||||
"socket.io-client": "4.0.1",
|
||||
"sqlite": "4.0.21",
|
||||
"sqlite3": "5.0.2",
|
||||
"supertest": "6.1.3",
|
||||
"ts-jest": "26.5.5",
|
||||
"ts-standard": "10.0.0",
|
||||
"typescript": "4.2.4"
|
||||
}
|
||||
}
|
17
src/__test__/application.test.ts
Normal file
17
src/__test__/application.test.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../application'
|
||||
import { usersLogoPath } from '../tools/configurations/constants'
|
||||
|
||||
describe('application', () => {
|
||||
it("returns a 404 on route that doesn't exist", async () => {
|
||||
return await request(application).post('/404routenotfound').send().expect(404)
|
||||
})
|
||||
|
||||
it('returns a 200 success code for users images', async () => {
|
||||
return await request(application)
|
||||
.get(`${usersLogoPath.name}/default.png`)
|
||||
.send()
|
||||
.expect(200)
|
||||
})
|
||||
})
|
4
src/__test__/setEnvsVars.ts
Normal file
4
src/__test__/setEnvsVars.ts
Normal file
@ -0,0 +1,4 @@
|
||||
process.env.DATABASE_DIALECT = 'sqlite'
|
||||
process.env.JWT_ACCESS_EXPIRES_IN = '15 minutes'
|
||||
process.env.JWT_ACCESS_SECRET = 'accessTokenSecret'
|
||||
process.env.JWT_REFRESH_SECRET = 'refreshTokenSecret'
|
42
src/__test__/setup.ts
Normal file
42
src/__test__/setup.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import fsMock from 'mock-fs'
|
||||
import path from 'path'
|
||||
import { Sequelize } from 'sequelize-typescript'
|
||||
import { Database, open } from 'sqlite'
|
||||
import sqlite3 from 'sqlite3'
|
||||
|
||||
let sqlite: Database | undefined
|
||||
let sequelize: Sequelize | undefined
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: () => {
|
||||
return {
|
||||
sendMail: jest.fn(async () => {})
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
beforeAll(async () => {
|
||||
sqlite = await open({
|
||||
filename: ':memory:',
|
||||
driver: sqlite3.Database
|
||||
})
|
||||
sequelize = new Sequelize({
|
||||
dialect: process.env.DATABASE_DIALECT,
|
||||
storage: process.env.DATABASE_DIALECT === 'sqlite' ? ':memory:' : undefined,
|
||||
logging: false,
|
||||
models: [path.join(__dirname, '..', 'models')]
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await sequelize?.sync({ force: true })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
fsMock.restore()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await sqlite?.close()
|
||||
await sequelize?.close()
|
||||
})
|
10
src/__test__/utils/__test__/formatErrors.test.ts
Normal file
10
src/__test__/utils/__test__/formatErrors.test.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { formatErrors } from '../formatErrors'
|
||||
|
||||
test('__test__/utils/formatErrors', () => {
|
||||
expect(formatErrors('randomSring')).toEqual([])
|
||||
const errors = [
|
||||
{ message: 'some error message' },
|
||||
{ message: 'another error' }
|
||||
]
|
||||
expect(formatErrors(errors)).toEqual(['some error message', 'another error'])
|
||||
})
|
57
src/__test__/utils/authenticateUser.ts
Normal file
57
src/__test__/utils/authenticateUser.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../application'
|
||||
import User from '../../models/User'
|
||||
|
||||
interface AuthenticateUserOptions {
|
||||
name?: string
|
||||
email?: string
|
||||
password?: string
|
||||
shouldBeConfirmed?: boolean
|
||||
alreadySignedUp?: boolean
|
||||
}
|
||||
|
||||
export async function authenticateUserTest (
|
||||
options: AuthenticateUserOptions = {}
|
||||
): Promise<{
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresIn: string
|
||||
type: 'Bearer'
|
||||
userId: number
|
||||
}> {
|
||||
const {
|
||||
name = 'John',
|
||||
email = 'contact@test.com',
|
||||
shouldBeConfirmed = true,
|
||||
password = 'test',
|
||||
alreadySignedUp = false
|
||||
} = options
|
||||
|
||||
if (!alreadySignedUp) {
|
||||
const { body: signupBody } = await request(application)
|
||||
.post('/users/signup')
|
||||
.send({ name, email, password })
|
||||
.expect(201)
|
||||
let signinResponse: any = { body: {} }
|
||||
if (shouldBeConfirmed) {
|
||||
const user = await User.findOne({ where: { id: signupBody.user.id } })
|
||||
await request(application)
|
||||
.get(`/users/confirmEmail?tempToken=${user?.tempToken as string}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
signinResponse = await request(application)
|
||||
.post('/users/signin')
|
||||
.send({ email, password })
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
return { ...signinResponse.body, userId: signupBody.user.id }
|
||||
}
|
||||
const signinResponse = await request(application)
|
||||
.post('/users/signin')
|
||||
.send({ email, password })
|
||||
.expect(200)
|
||||
const user = await User.findOne({ where: { email } })
|
||||
return { ...signinResponse.body, userId: user?.id }
|
||||
}
|
8
src/__test__/utils/formatErrors.ts
Normal file
8
src/__test__/utils/formatErrors.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/** formatErrors for testing purpose (no types safety) */
|
||||
export const formatErrors = (errors: any): string[] => {
|
||||
try {
|
||||
return errors.map((e: any) => e.message)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
5
src/__test__/utils/wait.ts
Normal file
5
src/__test__/utils/wait.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const wait = async (ms: number): Promise<void> => {
|
||||
return await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
45
src/application.ts
Normal file
45
src/application.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import 'express-async-errors'
|
||||
|
||||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import express, { Request } from 'express'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
|
||||
import { errorHandler } from './tools/middlewares/errorHandler'
|
||||
import { router } from './services'
|
||||
import { NotFoundError } from './tools/errors/NotFoundError'
|
||||
import { TooManyRequestsError } from './tools/errors/TooManyRequestsError'
|
||||
|
||||
const application = express()
|
||||
dotenv.config()
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
application.use(morgan<Request>('dev'))
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
const requestPerSecond = 2
|
||||
const seconds = 60
|
||||
const windowMs = seconds * 1000
|
||||
application.enable('trust proxy')
|
||||
application.use(
|
||||
rateLimit({
|
||||
windowMs,
|
||||
max: seconds * requestPerSecond,
|
||||
handler: () => {
|
||||
throw new TooManyRequestsError()
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
application.use(express.json())
|
||||
application.use(helmet())
|
||||
application.use(cors<Request>())
|
||||
application.use(router)
|
||||
application.use(() => {
|
||||
throw new NotFoundError()
|
||||
})
|
||||
application.use(errorHandler)
|
||||
|
||||
export default application
|
22
src/index.ts
Normal file
22
src/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { authorize } from '@thream/socketio-jwt'
|
||||
|
||||
import application from './application'
|
||||
import { socket } from './tools/socket'
|
||||
import { sequelize } from './tools/database/sequelize'
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '8080', 10)
|
||||
|
||||
sequelize
|
||||
.sync()
|
||||
.then(() => {
|
||||
const server = application.listen(PORT, () => {
|
||||
console.log('\x1b[36m%s\x1b[0m', `🚀 Server listening on port ${PORT}.`)
|
||||
})
|
||||
socket.init(server)
|
||||
socket.io?.use(
|
||||
authorize({
|
||||
secret: process.env.JWT_ACCESS_SECRET
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch((error) => console.error(error))
|
55
src/models/Channel.ts
Normal file
55
src/models/Channel.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import Guild from './Guild'
|
||||
import Message from './Message'
|
||||
|
||||
export const channelTypes = ['text', 'voice'] as const
|
||||
export type ChannelType = typeof channelTypes[number]
|
||||
|
||||
@Table
|
||||
export default class Channel extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false
|
||||
})
|
||||
name!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'text'
|
||||
})
|
||||
type!: ChannelType
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
})
|
||||
description!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
})
|
||||
isDefault!: boolean
|
||||
|
||||
@ForeignKey(() => Guild)
|
||||
@Column
|
||||
guildId!: number
|
||||
|
||||
@BelongsTo(() => Guild)
|
||||
guild!: Guild
|
||||
|
||||
@HasMany(() => Message)
|
||||
messages!: Message[]
|
||||
}
|
45
src/models/Guild.ts
Normal file
45
src/models/Guild.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Column, DataType, HasMany, Model, Table } from 'sequelize-typescript'
|
||||
import { guildsIconPath } from '../tools/configurations/constants'
|
||||
|
||||
import Channel from './Channel'
|
||||
import Invitation from './Invitation'
|
||||
import Member from './Member'
|
||||
|
||||
@Table
|
||||
export default class Guild extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false
|
||||
})
|
||||
name!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
})
|
||||
description!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: false,
|
||||
defaultValue: `${guildsIconPath.name}/default.png`
|
||||
})
|
||||
icon!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
})
|
||||
isPublic!: boolean
|
||||
|
||||
@HasMany(() => Member, { onDelete: 'CASCADE' })
|
||||
members!: Member[]
|
||||
|
||||
@HasMany(() => Invitation, { onDelete: 'CASCADE' })
|
||||
invitations!: Invitation[]
|
||||
|
||||
@HasMany(() => Channel)
|
||||
channels!: Channel[]
|
||||
}
|
40
src/models/Invitation.ts
Normal file
40
src/models/Invitation.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import Guild from './Guild'
|
||||
|
||||
@Table
|
||||
export default class Invitation extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false
|
||||
})
|
||||
value!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
allowNull: false
|
||||
})
|
||||
/** expiresIn is how long, in milliseconds, until the invitation expires. Note: 0 = never expires */
|
||||
expiresIn!: number
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
})
|
||||
isPublic!: boolean
|
||||
|
||||
@ForeignKey(() => Guild)
|
||||
@Column
|
||||
guildId!: number
|
||||
|
||||
@BelongsTo(() => Guild, { onDelete: 'CASCADE' })
|
||||
guild!: Guild
|
||||
}
|
48
src/models/Member.ts
Normal file
48
src/models/Member.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
HasMany,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import Channel from './Channel'
|
||||
import Guild from './Guild'
|
||||
import Message from './Message'
|
||||
import User from './User'
|
||||
|
||||
@Table
|
||||
export default class Member extends Model {
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
})
|
||||
isOwner!: boolean
|
||||
|
||||
@ForeignKey(() => Channel)
|
||||
@Column
|
||||
lastVisitedChannelId!: number
|
||||
|
||||
@BelongsTo(() => Channel)
|
||||
channel!: Channel
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId!: number
|
||||
|
||||
@BelongsTo(() => User)
|
||||
user!: User
|
||||
|
||||
@ForeignKey(() => Guild)
|
||||
@Column
|
||||
guildId!: number
|
||||
|
||||
@BelongsTo(() => Guild)
|
||||
guild!: Guild
|
||||
|
||||
@HasMany(() => Message, { onDelete: 'CASCADE' })
|
||||
messages!: Message[]
|
||||
}
|
51
src/models/Message.ts
Normal file
51
src/models/Message.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import Channel from './Channel'
|
||||
import Member from './Member'
|
||||
|
||||
export const messageTypes = ['text', 'file'] as const
|
||||
export type MessageType = typeof messageTypes[number]
|
||||
|
||||
@Table
|
||||
export default class Message extends Model {
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: false
|
||||
})
|
||||
value!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'text'
|
||||
})
|
||||
type!: MessageType
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'text/plain'
|
||||
})
|
||||
mimetype!: string
|
||||
|
||||
@ForeignKey(() => Member)
|
||||
@Column
|
||||
memberId!: number
|
||||
|
||||
@BelongsTo(() => Member)
|
||||
member!: Member
|
||||
|
||||
@ForeignKey(() => Channel)
|
||||
@Column
|
||||
channelId!: number
|
||||
|
||||
@BelongsTo(() => Channel)
|
||||
channel!: Channel
|
||||
}
|
38
src/models/OAuth.ts
Normal file
38
src/models/OAuth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import User from './User'
|
||||
|
||||
export const providers = ['google', 'github', 'discord'] as const
|
||||
export const strategies = [...providers, 'local'] as const
|
||||
|
||||
export type ProviderOAuth = typeof providers[number]
|
||||
export type AuthenticationStrategy = typeof strategies[number]
|
||||
|
||||
@Table
|
||||
export default class OAuth extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false
|
||||
})
|
||||
provider!: ProviderOAuth
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: false
|
||||
})
|
||||
providerId!: string
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId!: number
|
||||
|
||||
@BelongsTo(() => User)
|
||||
user!: User
|
||||
}
|
26
src/models/RefreshToken.ts
Normal file
26
src/models/RefreshToken.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import User from './User'
|
||||
|
||||
@Table
|
||||
export default class RefreshToken extends Model {
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: false
|
||||
})
|
||||
token!: string
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId!: number
|
||||
|
||||
@BelongsTo(() => User)
|
||||
user!: User
|
||||
}
|
112
src/models/User.ts
Normal file
112
src/models/User.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import {
|
||||
Column,
|
||||
DataType,
|
||||
HasMany,
|
||||
HasOne,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import Member from './Member'
|
||||
import OAuth, { AuthenticationStrategy } from './OAuth'
|
||||
import RefreshToken from './RefreshToken'
|
||||
import UserSetting from './UserSetting'
|
||||
import { deleteObjectAttributes } from '../tools/utils/deleteObjectAttributes'
|
||||
import { usersLogoPath } from '../tools/configurations/constants'
|
||||
|
||||
export const userHiddenAttributes = [
|
||||
'password',
|
||||
'tempToken',
|
||||
'tempExpirationToken'
|
||||
] as const
|
||||
export type UserHiddenAttributes = typeof userHiddenAttributes[number]
|
||||
export interface UserToJSON extends Omit<User, UserHiddenAttributes> {}
|
||||
|
||||
export interface UserJWT {
|
||||
id: number
|
||||
currentStrategy: AuthenticationStrategy
|
||||
}
|
||||
|
||||
export interface UserRequest {
|
||||
current: User
|
||||
currentStrategy: AuthenticationStrategy
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
@Table
|
||||
export default class User extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false
|
||||
})
|
||||
name!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true
|
||||
})
|
||||
email?: string
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true
|
||||
})
|
||||
password?: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
})
|
||||
status!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
})
|
||||
biography!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: false,
|
||||
defaultValue: `${usersLogoPath.name}/default.png`
|
||||
})
|
||||
logo!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
})
|
||||
isConfirmed!: boolean
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true
|
||||
})
|
||||
tempToken?: string | null
|
||||
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
allowNull: true
|
||||
})
|
||||
tempExpirationToken?: number | null
|
||||
|
||||
@HasMany(() => RefreshToken, { onDelete: 'CASCADE' })
|
||||
refreshTokens!: RefreshToken[]
|
||||
|
||||
@HasMany(() => OAuth, { onDelete: 'CASCADE' })
|
||||
OAuths!: OAuth[]
|
||||
|
||||
@HasMany(() => Member, { onDelete: 'CASCADE' })
|
||||
members!: Member[]
|
||||
|
||||
@HasOne(() => UserSetting, { onDelete: 'CASCADE' })
|
||||
settings!: UserSetting
|
||||
|
||||
toJSON (): UserToJSON {
|
||||
const attributes = Object.assign({}, this.get())
|
||||
return deleteObjectAttributes(attributes, userHiddenAttributes) as UserToJSON
|
||||
}
|
||||
}
|
65
src/models/UserSetting.ts
Normal file
65
src/models/UserSetting.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
BelongsTo,
|
||||
Column,
|
||||
DataType,
|
||||
ForeignKey,
|
||||
Model,
|
||||
Table
|
||||
} from 'sequelize-typescript'
|
||||
|
||||
import User from './User'
|
||||
import { deleteObjectAttributes } from '../tools/utils/deleteObjectAttributes'
|
||||
|
||||
export const userSettingHiddenAttributes = [
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'userId',
|
||||
'id'
|
||||
] as const
|
||||
export type UserSettingHiddenAttributes = typeof userSettingHiddenAttributes[number]
|
||||
export interface UserSettingToJSON
|
||||
extends Omit<UserSetting, UserSettingHiddenAttributes> {}
|
||||
|
||||
export const languages = ['fr', 'en'] as const
|
||||
export type Language = typeof languages[number]
|
||||
|
||||
export const themes = ['light', 'dark'] as const
|
||||
export type Theme = typeof themes[number]
|
||||
|
||||
@Table
|
||||
export default class UserSetting extends Model {
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'en'
|
||||
})
|
||||
language!: Language
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'dark'
|
||||
})
|
||||
theme!: Theme
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
defaultValue: false
|
||||
})
|
||||
isPublicEmail!: boolean
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column
|
||||
userId?: number
|
||||
|
||||
@BelongsTo(() => User)
|
||||
user!: User
|
||||
|
||||
toJSON (): UserSettingToJSON {
|
||||
const attributes = Object.assign({}, this.get())
|
||||
return deleteObjectAttributes(
|
||||
attributes,
|
||||
userSettingHiddenAttributes
|
||||
) as UserSettingToJSON
|
||||
}
|
||||
}
|
105
src/models/_data.sql
Normal file
105
src/models/_data.sql
Normal file
@ -0,0 +1,105 @@
|
||||
-- All users have the password `test`
|
||||
INSERT INTO `Users` (`id`, `name`, `email`, `password`, `status`, `biography`, `logo`, `isConfirmed`, `tempToken`, `tempExpirationToken`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'Divlo', 'contact@divlo.fr', '$2a$12$rdXfja1jtd88bgvKs4Pbl.yBBFJZP5Y0TcmqOCPm8Fy3BmQCnJHG2', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:47:36', '2021-03-04 12:48:30'),
|
||||
(2, 'Divlo2', 'divlogaming@gmail.com', '$2a$12$/aIvPyRbp/WUXN1FHwo0w.pBtT1dNls01L8SClpDXbBccjWD33trm', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:47:53', '2021-03-04 12:48:32'),
|
||||
(3, 'John Doe', 'johndoe@gmail.com', '$2a$12$3Qif9pviwoLLtTAQZqir7u4stLNU6E053EvDeso16aqvuahi7w1se', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:48:24', '2021-03-04 12:48:35'),
|
||||
(4, 'User', 'user@example.com', '$2a$12$SdgnEhy22aNQXwBRNDy/XeUNWLvu/MneA1Xfs2dtNhai.m/gP9xNi', '', '', '/uploads/users/default.png', 1, NULL, NULL, '2021-03-04 12:49:58', '2021-03-04 12:50:04');
|
||||
|
||||
INSERT INTO `UserSettings` (`id`, `language`, `theme`, `isPublicEmail`, `userId`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'en', 'dark', 0, 1, '2021-03-04 12:47:36', '2021-03-04 12:47:36'),
|
||||
(2, 'fr', 'dark', 0, 2, '2021-03-04 12:47:53', '2021-03-04 12:47:53'),
|
||||
(3, 'en', 'light', 0, 3, '2021-03-04 12:48:24', '2021-03-04 12:48:24'),
|
||||
(4, 'fr', 'light', 0, 4, '2021-03-04 12:49:58', '2021-03-04 12:49:58');
|
||||
|
||||
INSERT INTO `Guilds` (`id`, `name`, `description`, `icon`, `isPublic`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'Ligue.dev', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
|
||||
(2, 'Docstring', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
|
||||
(3, 'Read The Docs', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
|
||||
(4, 'Les Joies du Code', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
|
||||
(5, 'Firecamp', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
|
||||
(6, 'CodinGame', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
|
||||
(7, 'Leon AI', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
|
||||
(8, 'Academind', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
|
||||
(9, 'StandardJS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
|
||||
(10, 'Next.js', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
|
||||
(11, 'Tailwind CSS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
|
||||
(12, 'Vue Land', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
|
||||
(13, 'Nuxt.js', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
|
||||
(14, 'Reactiflux', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
|
||||
(15, 'Deno', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:25', '2021-03-04 12:55:25'),
|
||||
(16, 'fastify', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
|
||||
(17, 'MandarineTS', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
|
||||
(18, 'Olivia', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
|
||||
(19, 'yarnpkg', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
|
||||
(20, 'Qovery', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
|
||||
(21, 'The Design Collective', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
|
||||
(22, 'Tauri Apps', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
|
||||
(23, 'microsoft-python', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
|
||||
(24, 'AppBrewery', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
|
||||
(25, 'OpenSauced', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
|
||||
(26, 'Devsters', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
|
||||
(27, 'Coding Roads', '', '/uploads/guilds/default.png', 0, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
|
||||
|
||||
INSERT INTO `Channels` (`id`, `name`, `type`, `description`, `isDefault`, `guildId`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'general', 'text', '', 1, 1, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
|
||||
(2, 'general', 'text', '', 1, 2, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
|
||||
(3, 'general', 'text', '', 1, 3, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
|
||||
(4, 'general', 'text', '', 1, 4, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
|
||||
(5, 'general', 'text', '', 1, 5, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
|
||||
(6, 'general', 'text', '', 1, 6, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
|
||||
(7, 'general', 'text', '', 1, 7, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
|
||||
(8, 'general', 'text', '', 1, 8, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
|
||||
(9, 'general', 'text', '', 1, 9, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
|
||||
(10, 'general', 'text', '', 1, 10, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
|
||||
(11, 'general', 'text', '', 1, 11, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
|
||||
(12, 'general', 'text', '', 1, 12, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
|
||||
(13, 'general', 'text', '', 1, 13, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
|
||||
(14, 'general', 'text', '', 1, 14, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
|
||||
(15, 'general', 'text', '', 1, 15, '2021-03-04 12:55:26', '2021-03-04 12:55:26'),
|
||||
(16, 'general', 'text', '', 1, 16, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
|
||||
(17, 'general', 'text', '', 1, 17, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
|
||||
(18, 'general', 'text', '', 1, 18, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
|
||||
(19, 'general', 'text', '', 1, 19, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
|
||||
(20, 'general', 'text', '', 1, 20, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
|
||||
(21, 'general', 'text', '', 1, 21, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
|
||||
(22, 'general', 'text', '', 1, 22, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
|
||||
(23, 'general', 'text', '', 1, 23, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
|
||||
(24, 'general', 'text', '', 1, 24, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
|
||||
(25, 'general', 'text', '', 1, 25, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
|
||||
(26, 'general', 'text', '', 1, 26, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
|
||||
(27, 'general', 'text', '', 1, 27, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
|
||||
|
||||
INSERT INTO `Invitations` (`id`, `value`, `expiresIn`, `isPublic`, `guildId`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'firstinvitation', 0, 1, 1, '2021-03-04 13:09:06', '2021-03-04 13:09:06');
|
||||
|
||||
INSERT INTO `Members` (`id`, `isOwner`, `lastVisitedChannelId`, `userId`, `guildId`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 1, 1, 1, 1, '2021-03-04 12:51:27', '2021-03-04 12:51:27'),
|
||||
(2, 1, 2, 1, 2, '2021-03-04 12:51:39', '2021-03-04 12:51:39'),
|
||||
(3, 1, 3, 1, 3, '2021-03-04 12:51:50', '2021-03-04 12:51:50'),
|
||||
(4, 1, 4, 1, 4, '2021-03-04 12:52:09', '2021-03-04 12:52:09'),
|
||||
(5, 1, 5, 1, 5, '2021-03-04 12:52:19', '2021-03-04 12:52:19'),
|
||||
(6, 1, 6, 1, 6, '2021-03-04 12:52:30', '2021-03-04 12:52:30'),
|
||||
(7, 1, 7, 1, 7, '2021-03-04 12:52:38', '2021-03-04 12:52:38'),
|
||||
(8, 1, 8, 1, 8, '2021-03-04 12:52:45', '2021-03-04 12:52:45'),
|
||||
(9, 1, 9, 1, 9, '2021-03-04 12:52:57', '2021-03-04 12:52:57'),
|
||||
(10, 1, 10, 1, 10, '2021-03-04 12:53:08', '2021-03-04 12:53:08'),
|
||||
(11, 1, 11, 1, 11, '2021-03-04 12:54:58', '2021-03-04 12:54:58'),
|
||||
(12, 1, 12, 1, 12, '2021-03-04 12:55:04', '2021-03-04 12:55:04'),
|
||||
(13, 1, 13, 1, 13, '2021-03-04 12:55:11', '2021-03-04 12:55:11'),
|
||||
(14, 1, 14, 1, 14, '2021-03-04 12:55:16', '2021-03-04 12:55:16'),
|
||||
(15, 1, 15, 1, 15, '2021-03-04 12:55:26', '2021-03-04 12:55:26'),
|
||||
(16, 1, 16, 1, 16, '2021-03-04 12:55:33', '2021-03-04 12:55:33'),
|
||||
(17, 1, 17, 1, 17, '2021-03-04 12:55:48', '2021-03-04 12:55:48'),
|
||||
(18, 1, 18, 1, 18, '2021-03-04 12:55:56', '2021-03-04 12:55:56'),
|
||||
(19, 1, 19, 1, 19, '2021-03-04 12:56:19', '2021-03-04 12:56:19'),
|
||||
(20, 1, 20, 1, 20, '2021-03-04 12:56:25', '2021-03-04 12:56:25'),
|
||||
(21, 1, 21, 1, 21, '2021-03-04 12:56:46', '2021-03-04 12:56:46'),
|
||||
(22, 1, 22, 1, 22, '2021-03-04 12:56:52', '2021-03-04 12:56:52'),
|
||||
(23, 1, 23, 1, 23, '2021-03-04 12:57:06', '2021-03-04 12:57:06'),
|
||||
(24, 1, 24, 1, 24, '2021-03-04 12:57:17', '2021-03-04 12:57:17'),
|
||||
(25, 1, 25, 1, 25, '2021-03-04 12:57:23', '2021-03-04 12:57:23'),
|
||||
(26, 1, 26, 1, 26, '2021-03-04 12:57:39', '2021-03-04 12:57:39'),
|
||||
(27, 1, 27, 1, 27, '2021-03-04 12:57:49', '2021-03-04 12:57:49');
|
||||
|
||||
INSERT INTO `Messages` (`id`, `value`, `type`, `mimetype`, `memberId`, `channelId`, `createdAt`, `updatedAt`) VALUES
|
||||
(1, 'Hello world!', 'text', 'text/plain', 1, 1, '2021-03-04 13:08:22', '2021-03-04 13:08:22');
|
6
src/services/__docs__/components.yaml
Normal file
6
src/services/__docs__/components.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: 'http'
|
||||
scheme: 'bearer'
|
||||
bearerFormat: 'JWT'
|
87
src/services/__docs__/errors-definitions.yaml
Normal file
87
src/services/__docs__/errors-definitions.yaml
Normal file
@ -0,0 +1,87 @@
|
||||
definitions:
|
||||
BadRequestError:
|
||||
'400':
|
||||
description: 'Bad Request'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
errors:
|
||||
type: 'array'
|
||||
items:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
type: 'string'
|
||||
field:
|
||||
type: 'string'
|
||||
required:
|
||||
- 'message'
|
||||
|
||||
UnauthorizedError:
|
||||
'401':
|
||||
description: 'Unauthorized: Token is missing or invalid Bearer'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
errors:
|
||||
type: 'array'
|
||||
items:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
type: 'string'
|
||||
enum: ['Unauthorized: Token is missing or invalid Bearer']
|
||||
|
||||
ForbiddenError:
|
||||
'403':
|
||||
description: 'Forbidden'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
errors:
|
||||
type: 'array'
|
||||
items:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
type: 'string'
|
||||
enum: ['Forbidden']
|
||||
|
||||
NotFoundError:
|
||||
'404':
|
||||
description: 'Not Found'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
errors:
|
||||
type: 'array'
|
||||
items:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
type: 'string'
|
||||
enum: ['Not Found']
|
||||
|
||||
PayloadTooLargeError:
|
||||
'413':
|
||||
description: 'Payload Too Large'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
errors:
|
||||
type: 'array'
|
||||
items:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
type: 'string'
|
20
src/services/__docs__/utils.yaml
Normal file
20
src/services/__docs__/utils.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
definitions:
|
||||
PaginateModel:
|
||||
type: 'object'
|
||||
properties:
|
||||
hasMore:
|
||||
type: 'boolean'
|
||||
totalItems:
|
||||
type: 'number'
|
||||
itemsPerPage:
|
||||
type: 'number'
|
||||
page:
|
||||
type: 'number'
|
||||
PaginateModelParameters:
|
||||
'parameters':
|
||||
- name: 'itemsPerPage'
|
||||
in: 'query'
|
||||
required: false
|
||||
- name: 'page'
|
||||
in: 'query'
|
||||
required: false
|
25
src/services/channels/[channelId]/__docs__/delete.yaml
Normal file
25
src/services/channels/[channelId]/__docs__/delete.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
/channels/{channelId}:
|
||||
delete:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'channels'
|
||||
summary: 'DELETE a channel with its id'
|
||||
parameters:
|
||||
- name: 'channelId'
|
||||
in: 'path'
|
||||
required: true
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
deletedChannelId:
|
||||
type: 'number'
|
41
src/services/channels/[channelId]/__docs__/put.yaml
Normal file
41
src/services/channels/[channelId]/__docs__/put.yaml
Normal file
@ -0,0 +1,41 @@
|
||||
/channels/{channelId}:
|
||||
put:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'channels'
|
||||
summary: 'UPDATE a channel with its id'
|
||||
parameters:
|
||||
- name: 'channelId'
|
||||
in: 'path'
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
name:
|
||||
type: 'string'
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description:
|
||||
type: 'string'
|
||||
maxLength: 160
|
||||
isDefault:
|
||||
type: 'boolean'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
channel:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Channel'
|
71
src/services/channels/[channelId]/__test__/delete.test.ts
Normal file
71
src/services/channels/[channelId]/__test__/delete.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../application'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import { errorsMessages } from '../delete'
|
||||
import { createChannels } from '../../__test__/utils/createChannel'
|
||||
|
||||
describe('DELETE /channels/:channelId', () => {
|
||||
it('succeeds and delete the channel', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToDelete = result.channels[0]
|
||||
const response = await request(application)
|
||||
.delete(`/channels/${channelToDelete.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.deletedChannelId).toEqual(channelToDelete.id)
|
||||
const foundChannel = await Channel.findOne({
|
||||
where: { id: channelToDelete.id }
|
||||
})
|
||||
expect(foundChannel).toBeNull()
|
||||
})
|
||||
|
||||
it("fails if the channel doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.delete('/channels/23')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it('fails if the user is not the owner', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToDelete = result.channels[0]
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.delete(`/channels/${channelToDelete.id as number}`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it("fails if it's the default channel", async () => {
|
||||
const result = await createChannels([])
|
||||
const defaultChannel = await Channel.findOne({
|
||||
where: { guildId: result.guild.id as number, isDefault: true }
|
||||
})
|
||||
expect(defaultChannel).not.toBeNull()
|
||||
const response = await request(application)
|
||||
.delete(`/channels/${defaultChannel?.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([errorsMessages.channel.shouldNotBeTheDefault])
|
||||
)
|
||||
})
|
||||
})
|
120
src/services/channels/[channelId]/__test__/put.test.ts
Normal file
120
src/services/channels/[channelId]/__test__/put.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../application'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
|
||||
import { randomString } from '../../../../tools/utils/random'
|
||||
import { createChannels } from '../../__test__/utils/createChannel'
|
||||
|
||||
describe('PUT /channels/:channelId', () => {
|
||||
it('succeeds and edit name/description of the channel', async () => {
|
||||
const name = 'general-updated'
|
||||
const description = 'general-description'
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToEdit = result.channels[0]
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToEdit.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name, description })
|
||||
.expect(200)
|
||||
expect(response.body.channel.name).toEqual(name)
|
||||
expect(response.body.channel.description).toEqual(description)
|
||||
})
|
||||
|
||||
it('succeeds and set default channel to true', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToEdit = result.channels[0]
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToEdit.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ isDefault: true })
|
||||
.expect(200)
|
||||
const defaultChannels = await Channel.findAll({
|
||||
where: { guildId: result.guild.id as number, isDefault: true }
|
||||
})
|
||||
expect(defaultChannels.length).toEqual(1)
|
||||
expect(response.body.channel.name).toEqual(channel1.name)
|
||||
expect(response.body.channel.isDefault).toBeTruthy()
|
||||
})
|
||||
|
||||
it('succeeds with invalid slug name', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToEdit = result.channels[0]
|
||||
const name = 'random channel'
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToEdit.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name })
|
||||
.expect(200)
|
||||
expect(response.body.channel.name).toEqual(name)
|
||||
expect(response.body.channel.isDefault).toBeFalsy()
|
||||
})
|
||||
|
||||
it('fails with too long description', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToEdit = result.channels[0]
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToEdit.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ description: randomString(170) })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
commonErrorsMessages.charactersLength('description', { max: 160 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with too long name', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToEdit = result.channels[0]
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToEdit.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name: ' random channel name ' + randomString(35) })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if the channel doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.put('/channels/23')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it('fails if the user is not the owner', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const result = await createChannels([channel1])
|
||||
const channelToRemove = result.channels[0]
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.put(`/channels/${channelToRemove.id as number}`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
56
src/services/channels/[channelId]/delete.ts
Normal file
56
src/services/channels/[channelId]/delete.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
|
||||
import Channel from '../../../models/Channel'
|
||||
import Member from '../../../models/Member'
|
||||
import { BadRequestError } from '../../../tools/errors/BadRequestError'
|
||||
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../tools/errors/NotFoundError'
|
||||
import { deleteMessages } from '../../../tools/utils/deleteFiles'
|
||||
import Message from '../../../models/Message'
|
||||
import { emitToMembers } from '../../../tools/socket/emitEvents'
|
||||
|
||||
export const errorsMessages = {
|
||||
channel: {
|
||||
shouldNotBeTheDefault: 'The channel to delete should not be the default'
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteByIdChannelsRouter = Router()
|
||||
|
||||
deleteByIdChannelsRouter.delete(
|
||||
'/channels/:channelId',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { channelId } = req.params as { channelId: string }
|
||||
const channel = await Channel.findOne({
|
||||
where: { id: channelId },
|
||||
include: [Message]
|
||||
})
|
||||
if (channel == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId: channel.guildId, isOwner: true }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
if (channel.isDefault) {
|
||||
throw new BadRequestError(errorsMessages.channel.shouldNotBeTheDefault)
|
||||
}
|
||||
const deletedChannelId = channel.id
|
||||
await deleteMessages(channel.messages)
|
||||
await channel.destroy()
|
||||
await emitToMembers({
|
||||
event: 'channels',
|
||||
guildId: channel.guildId,
|
||||
payload: { action: 'delete', item: channel }
|
||||
})
|
||||
return res.status(200).json({ deletedChannelId })
|
||||
}
|
||||
)
|
33
src/services/channels/[channelId]/messages/__docs__/get.yaml
Normal file
33
src/services/channels/[channelId]/messages/__docs__/get.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
/channels/{channelId}/messages:
|
||||
get:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'messages'
|
||||
summary: 'GET all the messages of a channel'
|
||||
parameters:
|
||||
- name: 'channelId'
|
||||
in: 'path'
|
||||
required: true
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModelParameters'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModel'
|
||||
type: 'object'
|
||||
properties:
|
||||
rows:
|
||||
type: 'array'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Message'
|
||||
- $ref: '#/definitions/User'
|
@ -0,0 +1,44 @@
|
||||
/channels/{channelId}/messages:
|
||||
post:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'messages'
|
||||
summary: 'Create a new message'
|
||||
parameters:
|
||||
- name: 'channelId'
|
||||
in: 'path'
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
value:
|
||||
type: 'string'
|
||||
minLength: 1
|
||||
maxLength: 50_000
|
||||
type:
|
||||
allOf:
|
||||
- $ref: '#/definitions/MessageType'
|
||||
file:
|
||||
type: 'string'
|
||||
format: 'binary'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- $ref: '#/definitions/PayloadTooLargeError'
|
||||
- '201':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
message:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Message'
|
||||
- $ref: '#/definitions/User'
|
@ -0,0 +1,23 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../../../../application'
|
||||
import { createMessages } from '../../../../messages/__test__/utils/createMessages'
|
||||
|
||||
describe('GET /channels/:channelId/messages', () => {
|
||||
it('should get all the messages of the channel', async () => {
|
||||
const messages = ['Hello world!', 'some random message']
|
||||
const result = await createMessages(messages)
|
||||
const response = await request(application)
|
||||
.get(`/channels/${result.channelId}/messages`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.hasMore).toBeFalsy()
|
||||
expect(response.body.totalItems).toEqual(messages.length)
|
||||
expect(response.body.rows[0].value).toEqual(messages[0])
|
||||
expect(response.body.rows[1].value).toEqual(messages[1])
|
||||
expect(response.body.rows[1].user).not.toBeNull()
|
||||
expect(response.body.rows[1].user.id).toEqual(result.user.id)
|
||||
expect(response.body.rows[1].user.password).not.toBeDefined()
|
||||
})
|
||||
})
|
@ -0,0 +1,69 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../../application'
|
||||
import { createChannels } from '../../../__test__/utils/createChannel'
|
||||
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
|
||||
describe('POST /channels/:channelId/messages', () => {
|
||||
it('succeeds and create the message', async () => {
|
||||
const value = 'my awesome message'
|
||||
const result = await createChannels([channel1])
|
||||
expect(result.channels.length).toEqual(1)
|
||||
const channel = result.channels[0]
|
||||
const response = await request(application)
|
||||
.post(`/channels/${channel.id as number}/messages`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value, type: 'text' })
|
||||
.expect(201)
|
||||
expect(response.body.message).not.toBeNull()
|
||||
expect(response.body.message.value).toEqual(value)
|
||||
expect(response.body.message.type).toEqual('text')
|
||||
expect(response.body.message.user).not.toBeNull()
|
||||
expect(response.body.message.user.id).toEqual(result.user.id)
|
||||
})
|
||||
|
||||
it('fails with empty message', async () => {
|
||||
const result = await createChannels([channel1])
|
||||
expect(result.channels.length).toEqual(1)
|
||||
const channel = result.channels[0]
|
||||
const response1 = await request(application)
|
||||
.post(`/channels/${channel.id as number}/messages`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ type: 'text' })
|
||||
.expect(400)
|
||||
const response2 = await request(application)
|
||||
.post(`/channels/${channel.id as number}/messages`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ type: 'file' })
|
||||
.expect(400)
|
||||
expect(response1.body.errors.length).toEqual(1)
|
||||
expect(response2.body.errors.length).toEqual(1)
|
||||
})
|
||||
|
||||
it("fails if the channel doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.post('/channels/2/messages')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ type: 'text', value: 'awesome' })
|
||||
.expect(404)
|
||||
expect(response.body.errors.length).toEqual(1)
|
||||
})
|
||||
|
||||
it('fails if the user is not in the guild with this channel', async () => {
|
||||
const result = await createChannels([channel1])
|
||||
const channel = result.channels[0]
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.post(`/channels/${channel.id as number}/messages`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ value: 'some random message', type: 'text' })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
60
src/services/channels/[channelId]/messages/get.ts
Normal file
60
src/services/channels/[channelId]/messages/get.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import Member from '../../../../models/Member'
|
||||
import Message from '../../../../models/Message'
|
||||
import { paginateModel } from '../../../../tools/database/paginateModel'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
import User from '../../../../models/User'
|
||||
|
||||
export const getMessagesRouter = Router()
|
||||
|
||||
getMessagesRouter.get(
|
||||
'/channels/:channelId/messages',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const { itemsPerPage, page } = req.query as {
|
||||
itemsPerPage: string
|
||||
page: string
|
||||
}
|
||||
const { channelId } = req.params as { channelId: string }
|
||||
const user = req.user.current
|
||||
const channel = await Channel.findOne({ where: { id: channelId } })
|
||||
if (channel == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId: channel.guildId }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
member.lastVisitedChannelId = channel.id
|
||||
await member.save()
|
||||
const result = await paginateModel({
|
||||
Model: Message,
|
||||
queryOptions: { itemsPerPage, page },
|
||||
findOptions: {
|
||||
order: [['createdAt', 'DESC']],
|
||||
include: [{ model: Member, include: [User] }],
|
||||
where: {
|
||||
channelId: channel.id
|
||||
}
|
||||
}
|
||||
})
|
||||
return res.status(200).json({
|
||||
hasMore: result.hasMore,
|
||||
totalItems: result.totalItems,
|
||||
itemsPerPage: result.itemsPerPage,
|
||||
page: result.page,
|
||||
rows: result.rows.reverse().map((row: any) => {
|
||||
return { ...row.toJSON(), user: row.member.user.toJSON() }
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
9
src/services/channels/[channelId]/messages/index.ts
Normal file
9
src/services/channels/[channelId]/messages/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { postMessagesRouter } from './post'
|
||||
import { getMessagesRouter } from './get'
|
||||
|
||||
export const messagesChannelsRouter = Router()
|
||||
|
||||
messagesChannelsRouter.use('/', postMessagesRouter)
|
||||
messagesChannelsRouter.use('/', getMessagesRouter)
|
122
src/services/channels/[channelId]/messages/post.ts
Normal file
122
src/services/channels/[channelId]/messages/post.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { body } from 'express-validator'
|
||||
import fileUpload from 'express-fileupload'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import path from 'path'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import Member from '../../../../models/Member'
|
||||
import Message, { MessageType, messageTypes } from '../../../../models/Message'
|
||||
import {
|
||||
commonErrorsMessages,
|
||||
fileUploadOptions,
|
||||
messagesFilePath,
|
||||
tempPath
|
||||
} from '../../../../tools/configurations/constants'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
import { onlyPossibleValuesValidation } from '../../../../tools/validations/onlyPossibleValuesValidation'
|
||||
import { deleteAllFilesInDirectory } from '../../../../tools/utils/deleteFiles'
|
||||
import { PayloadTooLargeError } from '../../../../tools/errors/PayloadTooLargeError'
|
||||
import { BadRequestError } from '../../../../tools/errors/BadRequestError'
|
||||
import { emitToMembers } from '../../../../tools/socket/emitEvents'
|
||||
|
||||
export const errorsMessages = {
|
||||
type: {
|
||||
shouldNotBeEmpty: 'Type should not be empty'
|
||||
}
|
||||
}
|
||||
|
||||
export const postMessagesRouter = Router()
|
||||
|
||||
postMessagesRouter.post(
|
||||
'/channels/:channelId/messages',
|
||||
authenticateUser,
|
||||
fileUpload(fileUploadOptions),
|
||||
[
|
||||
body('value')
|
||||
.optional({ nullable: true })
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ min: 1, max: 50_000 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('value', { min: 1, max: 50_000 })
|
||||
),
|
||||
body('type')
|
||||
.notEmpty()
|
||||
.withMessage(errorsMessages.type.shouldNotBeEmpty)
|
||||
.trim()
|
||||
.isString()
|
||||
.custom(async (type: MessageType) => {
|
||||
return await onlyPossibleValuesValidation(messageTypes, 'type', type)
|
||||
})
|
||||
],
|
||||
validateRequest,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { value, type } = req.body as {
|
||||
value?: string
|
||||
type: MessageType
|
||||
}
|
||||
const file = req.files?.file
|
||||
const { channelId } = req.params as { channelId: string }
|
||||
const channel = await Channel.findOne({
|
||||
where: { id: channelId, type: 'text' }
|
||||
})
|
||||
if (channel == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId: channel.guildId }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
if (
|
||||
(type === 'file' && file == null) ||
|
||||
(type === 'text' && value == null)
|
||||
) {
|
||||
throw new BadRequestError("You can't send an empty message")
|
||||
}
|
||||
let filename: string | null = null
|
||||
let mimetype = 'text/plain'
|
||||
if (
|
||||
value == null &&
|
||||
type === 'file' &&
|
||||
file != null &&
|
||||
!Array.isArray(file)
|
||||
) {
|
||||
if (file.truncated) {
|
||||
await deleteAllFilesInDirectory(tempPath)
|
||||
throw new PayloadTooLargeError(
|
||||
commonErrorsMessages.tooLargeFile('file')
|
||||
)
|
||||
}
|
||||
mimetype = file.mimetype
|
||||
const splitedMimetype = mimetype.split('/')
|
||||
const fileExtension = splitedMimetype[1]
|
||||
filename = `${uuidv4()}.${fileExtension}`
|
||||
await file.mv(path.join(messagesFilePath.filePath, filename))
|
||||
await deleteAllFilesInDirectory(tempPath)
|
||||
}
|
||||
const messageCreated = await Message.create({
|
||||
value: filename != null ? `${messagesFilePath.name}/${filename}` : value,
|
||||
type,
|
||||
mimetype,
|
||||
memberId: member.id,
|
||||
channelId: channel.id
|
||||
})
|
||||
const message = { ...messageCreated.toJSON(), user: req.user.current }
|
||||
await emitToMembers({
|
||||
event: 'messages',
|
||||
guildId: member.guildId,
|
||||
payload: { action: 'create', item: message }
|
||||
})
|
||||
return res.status(201).json({ message })
|
||||
}
|
||||
)
|
92
src/services/channels/[channelId]/put.ts
Normal file
92
src/services/channels/[channelId]/put.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { body } from 'express-validator'
|
||||
|
||||
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
|
||||
import { validateRequest } from '../../../tools/middlewares/validateRequest'
|
||||
import Channel from '../../../models/Channel'
|
||||
import Member from '../../../models/Member'
|
||||
import { commonErrorsMessages } from '../../../tools/configurations/constants'
|
||||
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../tools/errors/NotFoundError'
|
||||
import { emitToMembers } from '../../../tools/socket/emitEvents'
|
||||
|
||||
export const putByIdChannelsRouter = Router()
|
||||
|
||||
putByIdChannelsRouter.put(
|
||||
'/channels/:channelId',
|
||||
authenticateUser,
|
||||
[
|
||||
body('name')
|
||||
.optional({ nullable: true })
|
||||
.isString()
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ max: 30, min: 3 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
|
||||
),
|
||||
body('description')
|
||||
.optional({ nullable: true })
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ max: 160 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('description', { max: 160 })
|
||||
),
|
||||
body('isDefault').optional({ nullable: true }).isBoolean()
|
||||
],
|
||||
validateRequest,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { channelId } = req.params as { channelId: string }
|
||||
const { name, description, isDefault } = req.body as {
|
||||
name?: string
|
||||
description?: string
|
||||
isDefault?: boolean
|
||||
}
|
||||
const channel = await Channel.findOne({
|
||||
where: { id: channelId }
|
||||
})
|
||||
if (channel == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId: channel.guildId, isOwner: true }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
channel.name = name ?? channel.name
|
||||
channel.description = description ?? channel.description
|
||||
if (isDefault != null) {
|
||||
const defaultChannel = await Channel.findOne({
|
||||
where: { isDefault: true, guildId: member.guildId }
|
||||
})
|
||||
if (isDefault && defaultChannel != null) {
|
||||
defaultChannel.isDefault = false
|
||||
channel.isDefault = true
|
||||
await defaultChannel.save()
|
||||
const defaultChannelMembers = await Member.findAll({
|
||||
where: {
|
||||
guildId: member.guildId,
|
||||
lastVisitedChannelId: defaultChannel.id
|
||||
}
|
||||
})
|
||||
for (const defaultChannelMember of defaultChannelMembers) {
|
||||
defaultChannelMember.lastVisitedChannelId = channel.id
|
||||
await defaultChannelMember.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
await channel.save()
|
||||
await emitToMembers({
|
||||
event: 'channels',
|
||||
guildId: channel.guildId,
|
||||
payload: { action: 'update', item: channel }
|
||||
})
|
||||
return res.status(200).json({ channel })
|
||||
}
|
||||
)
|
24
src/services/channels/__docs__/_definitions.yaml
Normal file
24
src/services/channels/__docs__/_definitions.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
definitions:
|
||||
Channel:
|
||||
type: 'object'
|
||||
properties:
|
||||
id:
|
||||
type: 'integer'
|
||||
description: 'Unique id'
|
||||
name:
|
||||
type: 'string'
|
||||
type:
|
||||
type: 'string'
|
||||
enum: ['text', 'voice']
|
||||
description:
|
||||
type: 'string'
|
||||
isDefault:
|
||||
type: 'boolean'
|
||||
guildId:
|
||||
type: 'integer'
|
||||
createdAt:
|
||||
type: 'string'
|
||||
format: 'date-time'
|
||||
updatedAt:
|
||||
type: 'string'
|
||||
format: 'date-time'
|
42
src/services/channels/__test__/utils/createChannel.ts
Normal file
42
src/services/channels/__test__/utils/createChannel.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../../../application'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import {
|
||||
createGuild,
|
||||
CreateGuildResult
|
||||
} from '../../../guilds/__test__/utils/createGuild'
|
||||
|
||||
interface ChannelOptions {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface CreateChannelsResult extends CreateGuildResult {
|
||||
channels: Channel[]
|
||||
}
|
||||
|
||||
export const createChannels = async (
|
||||
channels: ChannelOptions[]
|
||||
): Promise<CreateChannelsResult> => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const channelsResponses: Channel[] = []
|
||||
for (const { name, description } of channels) {
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name, description })
|
||||
.expect(201)
|
||||
channelsResponses.push(response.body.channel)
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
channels: channelsResponses
|
||||
}
|
||||
}
|
11
src/services/channels/index.ts
Normal file
11
src/services/channels/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { deleteByIdChannelsRouter } from './[channelId]/delete'
|
||||
import { messagesChannelsRouter } from './[channelId]/messages'
|
||||
import { putByIdChannelsRouter } from './[channelId]/put'
|
||||
|
||||
export const channelsRouter = Router()
|
||||
|
||||
channelsRouter.use('/', deleteByIdChannelsRouter)
|
||||
channelsRouter.use('/', putByIdChannelsRouter)
|
||||
channelsRouter.use('/', messagesChannelsRouter)
|
8
src/services/docs/index.ts
Normal file
8
src/services/docs/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
|
||||
import { swaggerSpecification } from '../../tools/configurations/swaggerSpecification'
|
||||
|
||||
export const documentationRouter = Router()
|
||||
|
||||
documentationRouter.use('/documentation', swaggerUi.serve, swaggerUi.setup(swaggerSpecification))
|
24
src/services/guilds/[guildId]/__docs__/delete.yaml
Normal file
24
src/services/guilds/[guildId]/__docs__/delete.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
/guilds/{guildId}:
|
||||
delete:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'guilds'
|
||||
summary: 'DELETE a guild with its id'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
deletedGuildId:
|
||||
type: 'number'
|
25
src/services/guilds/[guildId]/__docs__/get.yaml
Normal file
25
src/services/guilds/[guildId]/__docs__/get.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
/guilds/{guildId}:
|
||||
get:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'guilds'
|
||||
summary: 'GET a guild with its id'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
guild:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Guild'
|
48
src/services/guilds/[guildId]/__docs__/put.yaml
Normal file
48
src/services/guilds/[guildId]/__docs__/put.yaml
Normal file
@ -0,0 +1,48 @@
|
||||
/guilds/{guildId}:
|
||||
put:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'guilds'
|
||||
summary: 'Update a guild with its id'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
name:
|
||||
type: 'string'
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description:
|
||||
type: 'string'
|
||||
maxLength: 160
|
||||
icon:
|
||||
type: 'string'
|
||||
format: 'binary'
|
||||
isPublic:
|
||||
type: 'boolean'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
guild:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Guild'
|
||||
type: 'object'
|
||||
properties:
|
||||
publicInvitation:
|
||||
type: 'string'
|
62
src/services/guilds/[guildId]/__test__/delete.test.ts
Normal file
62
src/services/guilds/[guildId]/__test__/delete.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../application'
|
||||
import Guild from '../../../../models/Guild'
|
||||
import { createGuild } from '../../__test__/utils/createGuild'
|
||||
|
||||
describe('DELETE /guilds/:guildId', () => {
|
||||
it('succeeds and delete the guild', async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.delete(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.deletedGuildId).toEqual(result.guild.id)
|
||||
const foundGuild = await Guild.findOne({ where: { id: result?.guild.id as number } })
|
||||
expect(foundGuild).toBeNull()
|
||||
})
|
||||
|
||||
it("fails if the guild doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.delete('/guilds/23')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it("fails if the user isn't the owner", async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.delete(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
58
src/services/guilds/[guildId]/__test__/get.test.ts
Normal file
58
src/services/guilds/[guildId]/__test__/get.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../application'
|
||||
import { createGuild } from '../../__test__/utils/createGuild'
|
||||
|
||||
describe('GET /guilds/:guildId', () => {
|
||||
it('succeeds and get the guild', async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.guild.name).toEqual(name)
|
||||
expect(response.body.guild.description).toEqual(description)
|
||||
})
|
||||
|
||||
it("fails if the user isn't a member", async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'testing', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it("fails if the guild doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.get('/guilds/23')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
182
src/services/guilds/[guildId]/__test__/put.test.ts
Normal file
182
src/services/guilds/[guildId]/__test__/put.test.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../application'
|
||||
import Guild from '../../../../models/Guild'
|
||||
import Invitation from '../../../../models/Invitation'
|
||||
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
|
||||
import { randomString } from '../../../../tools/utils/random'
|
||||
import { createGuild } from '../../__test__/utils/createGuild'
|
||||
|
||||
describe('PUT /guilds/:guildId', () => {
|
||||
it('succeeds and edit the guild', async () => {
|
||||
const name = 'guild'
|
||||
const newName = 'guildtest'
|
||||
const description = 'testing'
|
||||
const newDescription = 'new description'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name: newName, description: newDescription })
|
||||
.expect(200)
|
||||
expect(response.body.guild.name).toEqual(newName)
|
||||
expect(response.body.guild.description).toEqual(newDescription)
|
||||
expect(response.body.guild.publicInvitation).toBeNull()
|
||||
const foundGuild = await Guild.findOne({
|
||||
where: { id: result?.guild.id as number }
|
||||
})
|
||||
expect(foundGuild?.name).toEqual(newName)
|
||||
expect(foundGuild?.description).toEqual(newDescription)
|
||||
})
|
||||
|
||||
it('succeeds and create/delete public invitations', async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const resIsPublic = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ isPublic: true })
|
||||
.expect(200)
|
||||
expect(resIsPublic.body.guild.isPublic).toBeTruthy()
|
||||
expect(typeof resIsPublic.body.guild.publicInvitation).toBe('string')
|
||||
const publicInvitation = await Invitation.findOne({
|
||||
where: { isPublic: true, guildId: result?.guild.id as number }
|
||||
})
|
||||
expect(publicInvitation).not.toBeNull()
|
||||
expect(publicInvitation?.expiresIn).toEqual(0)
|
||||
|
||||
const resIsNotPublic = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ isPublic: false })
|
||||
.expect(200)
|
||||
expect(resIsNotPublic.body.guild.isPublic).toBeFalsy()
|
||||
expect(resIsNotPublic.body.guild.publicInvitation).toBeNull()
|
||||
const notPublicInvitation = await Invitation.findOne({
|
||||
where: { isPublic: false, guildId: result?.guild.id as number }
|
||||
})
|
||||
expect(notPublicInvitation).toBeNull()
|
||||
})
|
||||
|
||||
it("fails if the user isn't the owner", async () => {
|
||||
const name = 'guild'
|
||||
const newName = 'guildtest'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ name: newName })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it("fails if the guild doesn't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const response = await request(application)
|
||||
.put('/guilds/23')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ name: 'kjdjhdjh' })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it('fails with invalid name', async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name: randomString(35) })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with name already used', async () => {
|
||||
const { guild } = await createGuild({
|
||||
guild: { description: 'testing', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const result = await createGuild({
|
||||
guild: { description: 'testing', name: 'guild2' },
|
||||
user: {
|
||||
email: 'test@test2.com',
|
||||
name: 'Test2'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name: guild.name })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Name already used']))
|
||||
})
|
||||
|
||||
it('fails with invalid description', async () => {
|
||||
const name = 'guild'
|
||||
const description = 'testing'
|
||||
const result = await createGuild({
|
||||
guild: { description, name },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.put(`/guilds/${result.guild.id as number}`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ description: randomString(165) })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
commonErrorsMessages.charactersLength('description', { max: 160 })
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
31
src/services/guilds/[guildId]/channels/__docs__/get.yaml
Normal file
31
src/services/guilds/[guildId]/channels/__docs__/get.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
/guilds/{guildId}/channels:
|
||||
get:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'channels'
|
||||
summary: 'GET all the channels of a guild'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModelParameters'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModel'
|
||||
type: 'object'
|
||||
properties:
|
||||
rows:
|
||||
type: 'array'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Channel'
|
39
src/services/guilds/[guildId]/channels/__docs__/post.yaml
Normal file
39
src/services/guilds/[guildId]/channels/__docs__/post.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
/guilds/{guildId}/channels:
|
||||
post:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'channels'
|
||||
summary: 'Create a channel'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
name:
|
||||
type: 'string'
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description:
|
||||
type: 'string'
|
||||
maxLength: 160
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '201':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
channel:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Channel'
|
23
src/services/guilds/[guildId]/channels/__test__/get.test.ts
Normal file
23
src/services/guilds/[guildId]/channels/__test__/get.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../../../../application'
|
||||
import { createChannels } from '../../../../channels/__test__/utils/createChannel'
|
||||
|
||||
describe('GET /guilds/:guildId/channels', () => {
|
||||
it('should get all the channels of the guild', async () => {
|
||||
const channel1 = { name: 'general1', description: 'testing' }
|
||||
const channel2 = { name: 'general2', description: 'testing' }
|
||||
const result = await createChannels([channel1, channel2])
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result.guild.id as number}/channels/`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.hasMore).toBeFalsy()
|
||||
expect(response.body.rows.length).toEqual(3)
|
||||
expect(response.body.rows[0].name).toEqual(channel2.name)
|
||||
expect(response.body.rows[0].description).toEqual(channel2.description)
|
||||
expect(response.body.rows[1].name).toEqual(channel1.name)
|
||||
expect(response.body.rows[1].description).toEqual(channel1.description)
|
||||
})
|
||||
})
|
146
src/services/guilds/[guildId]/channels/__test__/post.test.ts
Normal file
146
src/services/guilds/[guildId]/channels/__test__/post.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../../application'
|
||||
import { commonErrorsMessages } from '../../../../../tools/configurations/constants'
|
||||
import { randomString } from '../../../../../tools/utils/random'
|
||||
import { createGuild } from '../../../__test__/utils/createGuild'
|
||||
import { errorsMessages } from '../post'
|
||||
|
||||
describe('POST /guilds/:guildId/channels', () => {
|
||||
it('succeeds with valid name/description', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const name = 'channel-name'
|
||||
const description = 'testing channel creation'
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name, description })
|
||||
.expect(201)
|
||||
expect(response.body.channel).not.toBeNull()
|
||||
expect(response.body.channel.guildId).not.toBeUndefined()
|
||||
expect(response.body.channel.name).toBe(name)
|
||||
expect(response.body.channel.description).toBe(description)
|
||||
})
|
||||
|
||||
it('succeeds with only channel name', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const name = 'channel-name'
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name })
|
||||
.expect(201)
|
||||
expect(response.body.channel).not.toBeNull()
|
||||
expect(response.body.channel.name).toBe(name)
|
||||
})
|
||||
|
||||
it('succeeds with invalid slug name', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const name = 'channel name'
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name, description: 'testing' })
|
||||
.expect(201)
|
||||
expect(response.body.channel).not.toBeNull()
|
||||
expect(response.body.channel.name).toBe(name)
|
||||
})
|
||||
|
||||
it('fails without name', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ description: 'testing channel creation' })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(3)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
errorsMessages.name.isRequired,
|
||||
commonErrorsMessages.charactersLength('name', { min: 3, max: 30 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with invalid description', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ name: 'channel-name', description: randomString(170) })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
commonErrorsMessages.charactersLength('description', { max: 160 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("fails if the user isn't the owner", async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const userToken = await authenticateUserTest()
|
||||
const name = 'channel-name'
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/channels`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ name, description: 'testing channel creation' })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
|
||||
it("fails if the guild does't exist", async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const name = 'channel-name'
|
||||
const response = await request(application)
|
||||
.post('/guilds/1/channels')
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ name, description: 'testing channel creation' })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
43
src/services/guilds/[guildId]/channels/get.ts
Normal file
43
src/services/guilds/[guildId]/channels/get.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import Member from '../../../../models/Member'
|
||||
import { paginateModel } from '../../../../tools/database/paginateModel'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
|
||||
export const getChannelsRouter = Router()
|
||||
|
||||
getChannelsRouter.get(
|
||||
'/guilds/:guildId/channels',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const { itemsPerPage, page } = req.query as {
|
||||
itemsPerPage: string
|
||||
page: string
|
||||
}
|
||||
const user = req.user.current
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const channels = await paginateModel({
|
||||
Model: Channel,
|
||||
queryOptions: { itemsPerPage, page },
|
||||
findOptions: {
|
||||
order: [['createdAt', 'DESC']],
|
||||
where: {
|
||||
guildId: member.guildId
|
||||
}
|
||||
}
|
||||
})
|
||||
return res.status(200).json(channels)
|
||||
}
|
||||
)
|
9
src/services/guilds/[guildId]/channels/index.ts
Normal file
9
src/services/guilds/[guildId]/channels/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getChannelsRouter } from './get'
|
||||
import { postChannelsRouter } from './post'
|
||||
|
||||
export const guildsChannelsRouter = Router()
|
||||
|
||||
guildsChannelsRouter.use('/', getChannelsRouter)
|
||||
guildsChannelsRouter.use('/', postChannelsRouter)
|
73
src/services/guilds/[guildId]/channels/post.ts
Normal file
73
src/services/guilds/[guildId]/channels/post.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { body } from 'express-validator'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
|
||||
import Channel from '../../../../models/Channel'
|
||||
import Member from '../../../../models/Member'
|
||||
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
import { emitToMembers } from '../../../../tools/socket/emitEvents'
|
||||
|
||||
export const errorsMessages = {
|
||||
name: {
|
||||
isRequired: 'Name is required'
|
||||
}
|
||||
}
|
||||
|
||||
export const postChannelsRouter = Router()
|
||||
|
||||
postChannelsRouter.post(
|
||||
'/guilds/:guildId/channels',
|
||||
authenticateUser,
|
||||
[
|
||||
body('name')
|
||||
.notEmpty()
|
||||
.withMessage(errorsMessages.name.isRequired)
|
||||
.isString()
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ max: 30, min: 3 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
|
||||
),
|
||||
body('description')
|
||||
.optional({ nullable: true })
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ max: 160 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('description', { max: 160 })
|
||||
)
|
||||
],
|
||||
validateRequest,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { name, description = '' } = req.body as {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId, isOwner: true }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const channel = await Channel.create({
|
||||
name,
|
||||
description,
|
||||
guildId: member.guildId
|
||||
})
|
||||
await emitToMembers({
|
||||
event: 'channels',
|
||||
guildId: member.guildId,
|
||||
payload: { action: 'create', item: channel }
|
||||
})
|
||||
return res.status(201).json({ channel })
|
||||
}
|
||||
)
|
57
src/services/guilds/[guildId]/delete.ts
Normal file
57
src/services/guilds/[guildId]/delete.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
|
||||
import Guild from '../../../models/Guild'
|
||||
import Member from '../../../models/Member'
|
||||
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../tools/errors/NotFoundError'
|
||||
import { guildsIconPath } from '../../../tools/configurations/constants'
|
||||
import { deleteFile, deleteMessages } from '../../../tools/utils/deleteFiles'
|
||||
import Channel from '../../../models/Channel'
|
||||
import Message from '../../../models/Message'
|
||||
import { emitToMembers } from '../../../tools/socket/emitEvents'
|
||||
|
||||
export const deleteByIdGuildsRouter = Router()
|
||||
|
||||
deleteByIdGuildsRouter.delete(
|
||||
'/guilds/:guildId',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId, isOwner: true },
|
||||
include: [Guild]
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const deletedGuildId = member.guild.id
|
||||
await emitToMembers({
|
||||
event: 'guilds',
|
||||
guildId: member.guildId,
|
||||
payload: { action: 'delete', item: member.guild }
|
||||
})
|
||||
await deleteFile({
|
||||
basePath: guildsIconPath,
|
||||
valueSavedInDatabase: member.guild.icon
|
||||
})
|
||||
const members = await Member.findAll({ where: { guildId: deletedGuildId } })
|
||||
for (const member of members) {
|
||||
await member.destroy()
|
||||
}
|
||||
const channels = await Channel.findAll({
|
||||
where: { guildId },
|
||||
include: [Message]
|
||||
})
|
||||
for (const channel of channels) {
|
||||
await deleteMessages(channel.messages)
|
||||
await channel.destroy()
|
||||
}
|
||||
await member.guild.destroy()
|
||||
return res.status(200).json({ deletedGuildId })
|
||||
}
|
||||
)
|
29
src/services/guilds/[guildId]/get.ts
Normal file
29
src/services/guilds/[guildId]/get.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
|
||||
import Guild from '../../../models/Guild'
|
||||
import Member from '../../../models/Member'
|
||||
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../tools/errors/NotFoundError'
|
||||
|
||||
export const getByIdGuildsRouter = Router()
|
||||
|
||||
getByIdGuildsRouter.get(
|
||||
'/guilds/:guildId',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId },
|
||||
include: [Guild]
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
return res.status(200).json({ guild: member.guild })
|
||||
}
|
||||
)
|
19
src/services/guilds/[guildId]/index.ts
Normal file
19
src/services/guilds/[guildId]/index.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { deleteByIdGuildsRouter } from './delete'
|
||||
import { getByIdGuildsRouter } from './get'
|
||||
import { putByIdGuildsRouter } from './put'
|
||||
|
||||
import { guildsChannelsRouter } from './channels'
|
||||
import { guildsInvitationsRouter } from './invitations'
|
||||
import { guildsMembersRouter } from './members'
|
||||
|
||||
export const guildsGetByIdRouter = Router()
|
||||
|
||||
guildsGetByIdRouter.use('/', getByIdGuildsRouter)
|
||||
guildsGetByIdRouter.use('/', deleteByIdGuildsRouter)
|
||||
guildsGetByIdRouter.use('/', putByIdGuildsRouter)
|
||||
|
||||
guildsGetByIdRouter.use('/', guildsChannelsRouter)
|
||||
guildsGetByIdRouter.use('/', guildsInvitationsRouter)
|
||||
guildsGetByIdRouter.use('/', guildsMembersRouter)
|
31
src/services/guilds/[guildId]/invitations/__docs__/get.yaml
Normal file
31
src/services/guilds/[guildId]/invitations/__docs__/get.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
/guilds/{guildId}/invitations:
|
||||
get:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'invitations'
|
||||
summary: 'GET all the invitations of a guild'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModelParameters'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModel'
|
||||
type: 'object'
|
||||
properties:
|
||||
rows:
|
||||
type: 'array'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Invitation'
|
40
src/services/guilds/[guildId]/invitations/__docs__/post.yaml
Normal file
40
src/services/guilds/[guildId]/invitations/__docs__/post.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
/guilds/{guildId}/invitations:
|
||||
post:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'invitations'
|
||||
summary: 'Create an invitation'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
value:
|
||||
type: 'string'
|
||||
minLength: 1
|
||||
maxLength: 250
|
||||
expiresIn:
|
||||
type: 'integer'
|
||||
isPublic:
|
||||
type: 'boolean'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- $ref: '#/definitions/NotFoundError'
|
||||
- '201':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: 'object'
|
||||
properties:
|
||||
invitation:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Invitation'
|
@ -0,0 +1,46 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../../../../application'
|
||||
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
|
||||
import { createInvitation } from '../../../../invitations/__test__/utils/createInvitation'
|
||||
|
||||
describe('GET /guilds/:guildId/invitations', () => {
|
||||
it('should get all the invitations of the guild', async () => {
|
||||
const value1 = 'awesome'
|
||||
const value2 = 'awesomevalue'
|
||||
const result = await createInvitation({ value: value1 })
|
||||
await createInvitation({
|
||||
value: value2,
|
||||
guildId: result?.guild.id
|
||||
})
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result?.guild.id as number}/invitations`)
|
||||
.set(
|
||||
'Authorization',
|
||||
`${result?.user.type as string} ${result?.user.accessToken as string}`
|
||||
)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.hasMore).toBeFalsy()
|
||||
expect(response.body.rows.length).toEqual(2)
|
||||
expect(response.body.rows[0].value).toEqual(value2)
|
||||
expect(response.body.rows[1].value).toEqual(value1)
|
||||
})
|
||||
|
||||
it('fails if the user is not the owner', async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const result = await createInvitation()
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result?.guild.id as number}/invitations`)
|
||||
.set(
|
||||
'Authorization',
|
||||
`${userToken.type as string} ${userToken.accessToken}`
|
||||
)
|
||||
.send()
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
163
src/services/guilds/[guildId]/invitations/__test__/post.test.ts
Normal file
163
src/services/guilds/[guildId]/invitations/__test__/post.test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
|
||||
import { formatErrors } from '../../../../../__test__/utils/formatErrors'
|
||||
import application from '../../../../../application'
|
||||
import { createGuild } from '../../../__test__/utils/createGuild'
|
||||
import { errorsMessages } from '../post'
|
||||
import { commonErrorsMessages } from '../../../../../tools/configurations/constants'
|
||||
|
||||
describe('POST /guilds/:guildId/invitations', () => {
|
||||
it('succeeds and create the invitation', async () => {
|
||||
const value = 'random'
|
||||
const expiresIn = 0
|
||||
const isPublic = false
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value, expiresIn, isPublic })
|
||||
.expect(201)
|
||||
expect(response.body.invitation.value).toEqual(value)
|
||||
expect(response.body.invitation.expiresIn).toEqual(expiresIn)
|
||||
expect(response.body.invitation.isPublic).toEqual(isPublic)
|
||||
})
|
||||
|
||||
it('fails with empty value', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ expiresIn: 0 })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(3)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
errorsMessages.value.shouldNotBeEmpty,
|
||||
errorsMessages.value.mustBeSlug,
|
||||
commonErrorsMessages.charactersLength('value', { max: 250, min: 1 })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with invalid slug value', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value: 'random value' })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([errorsMessages.value.mustBeSlug])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails with negative expiresIn', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value: 'awesome', expiresIn: -42 })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([errorsMessages.expiresIn.mustBeGreaterOrEqual])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if the invitation slug value already exists', async () => {
|
||||
const value = 'awesome'
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value })
|
||||
.expect(201)
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Value already used']))
|
||||
})
|
||||
|
||||
it('fails with isPublic: true - if there is already a public invitation for this guild', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value: 'awesome', isPublic: true })
|
||||
.expect(201)
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send({ value: 'awesome2', isPublic: true })
|
||||
.expect(400)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(
|
||||
expect.arrayContaining([errorsMessages.public.alreadyHasInvitation])
|
||||
)
|
||||
})
|
||||
|
||||
it('fails if the user is not the owner', async () => {
|
||||
const userToken = await authenticateUserTest()
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const response = await request(application)
|
||||
.post(`/guilds/${result.guild.id as number}/invitations`)
|
||||
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
|
||||
.send({ value: 'value' })
|
||||
.expect(404)
|
||||
const errors = formatErrors(response.body.errors)
|
||||
expect(errors.length).toEqual(1)
|
||||
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
|
||||
})
|
||||
})
|
43
src/services/guilds/[guildId]/invitations/get.ts
Normal file
43
src/services/guilds/[guildId]/invitations/get.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import Invitation from '../../../../models/Invitation'
|
||||
import Member from '../../../../models/Member'
|
||||
import { paginateModel } from '../../../../tools/database/paginateModel'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
|
||||
export const getInvitationsRouter = Router()
|
||||
|
||||
getInvitationsRouter.get(
|
||||
'/guilds/:guildId/invitations',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const { itemsPerPage, page } = req.query as {
|
||||
itemsPerPage: string
|
||||
page: string
|
||||
}
|
||||
const user = req.user.current
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId, isOwner: true }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const invitations = await paginateModel({
|
||||
Model: Invitation,
|
||||
queryOptions: { itemsPerPage, page },
|
||||
findOptions: {
|
||||
order: [['createdAt', 'DESC']],
|
||||
where: {
|
||||
guildId: member.guildId
|
||||
}
|
||||
}
|
||||
})
|
||||
return res.status(200).json(invitations)
|
||||
}
|
||||
)
|
9
src/services/guilds/[guildId]/invitations/index.ts
Normal file
9
src/services/guilds/[guildId]/invitations/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { postInvitationsRouter } from './post'
|
||||
import { getInvitationsRouter } from './get'
|
||||
|
||||
export const guildsInvitationsRouter = Router()
|
||||
|
||||
guildsInvitationsRouter.use('/', postInvitationsRouter)
|
||||
guildsInvitationsRouter.use('/', getInvitationsRouter)
|
89
src/services/guilds/[guildId]/invitations/post.ts
Normal file
89
src/services/guilds/[guildId]/invitations/post.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import { body } from 'express-validator'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
|
||||
import Invitation from '../../../../models/Invitation'
|
||||
import Member from '../../../../models/Member'
|
||||
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
|
||||
import { alreadyUsedValidation } from '../../../../tools/validations/alreadyUsedValidation'
|
||||
import { BadRequestError } from '../../../../tools/errors/BadRequestError'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
|
||||
export const errorsMessages = {
|
||||
value: {
|
||||
mustBeSlug: 'Value must be a slug',
|
||||
shouldNotBeEmpty: 'Value should not be empty'
|
||||
},
|
||||
expiresIn: {
|
||||
mustBeGreaterOrEqual: 'ExpiresIn must be >= 0'
|
||||
},
|
||||
public: {
|
||||
alreadyHasInvitation: 'There is already a public invitation for this guild'
|
||||
}
|
||||
}
|
||||
|
||||
export const postInvitationsRouter = Router()
|
||||
|
||||
postInvitationsRouter.post(
|
||||
'/guilds/:guildId/invitations',
|
||||
authenticateUser,
|
||||
[
|
||||
body('value')
|
||||
.notEmpty()
|
||||
.withMessage(errorsMessages.value.shouldNotBeEmpty)
|
||||
.trim()
|
||||
.escape()
|
||||
.isLength({ max: 250, min: 1 })
|
||||
.withMessage(
|
||||
commonErrorsMessages.charactersLength('value', { max: 250, min: 1 })
|
||||
)
|
||||
.isSlug()
|
||||
.withMessage(errorsMessages.value.mustBeSlug)
|
||||
.custom(async (value: string) => {
|
||||
return await alreadyUsedValidation(Invitation, 'value', value)
|
||||
}),
|
||||
body('expiresIn')
|
||||
.optional({ nullable: true })
|
||||
.isInt({ min: 0 })
|
||||
.withMessage(errorsMessages.expiresIn.mustBeGreaterOrEqual),
|
||||
body('isPublic').optional({ nullable: true }).isBoolean()
|
||||
],
|
||||
validateRequest,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const user = req.user.current
|
||||
const { value, expiresIn = 0, isPublic = false } = req.body as {
|
||||
value: string
|
||||
expiresIn?: number
|
||||
isPublic?: boolean
|
||||
}
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId, isOwner: true }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const foundInvitation = await Invitation.findOne({
|
||||
where: { isPublic: true, guildId: member.guildId }
|
||||
})
|
||||
if (isPublic && foundInvitation != null) {
|
||||
throw new BadRequestError(errorsMessages.public.alreadyHasInvitation)
|
||||
}
|
||||
let expiresInValue = expiresIn
|
||||
if (expiresInValue > 0) {
|
||||
expiresInValue += Date.now()
|
||||
}
|
||||
const invitation = await Invitation.create({
|
||||
value,
|
||||
expiresIn,
|
||||
isPublic,
|
||||
guildId: member.guildId
|
||||
})
|
||||
return res.status(201).json({ invitation })
|
||||
}
|
||||
)
|
32
src/services/guilds/[guildId]/members/__docs__/get.yaml
Normal file
32
src/services/guilds/[guildId]/members/__docs__/get.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
/guilds/{guildId}/members:
|
||||
get:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- 'members'
|
||||
summary: 'GET all the members of a guild'
|
||||
parameters:
|
||||
- name: 'guildId'
|
||||
in: 'path'
|
||||
required: true
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModelParameters'
|
||||
responses:
|
||||
allOf:
|
||||
- $ref: '#/definitions/UnauthorizedError'
|
||||
- $ref: '#/definitions/ForbiddenError'
|
||||
- $ref: '#/definitions/BadRequestError'
|
||||
- '200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/PaginateModel'
|
||||
type: 'object'
|
||||
properties:
|
||||
rows:
|
||||
type: 'array'
|
||||
items:
|
||||
allOf:
|
||||
- $ref: '#/definitions/Member'
|
||||
- $ref: '#/definitions/User'
|
35
src/services/guilds/[guildId]/members/__test__/get.test.ts
Normal file
35
src/services/guilds/[guildId]/members/__test__/get.test.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import request from 'supertest'
|
||||
|
||||
import application from '../../../../../application'
|
||||
import Member from '../../../../../models/Member'
|
||||
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
|
||||
import { createGuild } from '../../../__test__/utils/createGuild'
|
||||
|
||||
describe('GET /guilds/:guildId/members', () => {
|
||||
it('should get all the members of a guild', async () => {
|
||||
const result = await createGuild({
|
||||
guild: { description: 'description', name: 'guild' },
|
||||
user: {
|
||||
email: 'test@test.com',
|
||||
name: 'Test'
|
||||
}
|
||||
})
|
||||
const userToken = await authenticateUserTest()
|
||||
await Member.create({
|
||||
userId: userToken.userId,
|
||||
guildId: result.guild.id,
|
||||
lastVisitedChannelId: 1
|
||||
})
|
||||
const response = await request(application)
|
||||
.get(`/guilds/${result.guild.id as number}/members`)
|
||||
.set('Authorization', `${result.user.type} ${result.user.accessToken}`)
|
||||
.send()
|
||||
.expect(200)
|
||||
expect(response.body.hasMore).toBeFalsy()
|
||||
expect(response.body.totalItems).toEqual(2)
|
||||
expect(response.body.rows[0].guildId).toEqual(result.guild.id)
|
||||
expect(response.body.rows[1].guildId).toEqual(result.guild.id)
|
||||
expect(response.body.rows[1].user).not.toBeNull()
|
||||
expect(response.body.rows[1].user.password).not.toBeDefined()
|
||||
})
|
||||
})
|
50
src/services/guilds/[guildId]/members/get.ts
Normal file
50
src/services/guilds/[guildId]/members/get.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
|
||||
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
|
||||
import Member from '../../../../models/Member'
|
||||
import { paginateModel } from '../../../../tools/database/paginateModel'
|
||||
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
|
||||
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
|
||||
|
||||
export const getMembersRouter = Router()
|
||||
|
||||
getMembersRouter.get(
|
||||
'/guilds/:guildId/members',
|
||||
authenticateUser,
|
||||
async (req: Request, res: Response) => {
|
||||
if (req.user == null) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
const { itemsPerPage, page } = req.query as {
|
||||
itemsPerPage: string
|
||||
page: string
|
||||
}
|
||||
const user = req.user.current
|
||||
const { guildId } = req.params as { guildId: string }
|
||||
const member = await Member.findOne({
|
||||
where: { userId: user.id, guildId }
|
||||
})
|
||||
if (member == null) {
|
||||
throw new NotFoundError()
|
||||
}
|
||||
const result = await paginateModel({
|
||||
Model: Member,
|
||||
queryOptions: { itemsPerPage, page },
|
||||
findOptions: {
|
||||
order: [['createdAt', 'DESC']],
|
||||
where: {
|
||||
guildId: member.guildId
|
||||
}
|
||||
}
|
||||
})
|
||||
return res.status(200).json({
|
||||
hasMore: result.hasMore,
|
||||
totalItems: result.totalItems,
|
||||
itemsPerPage: result.itemsPerPage,
|
||||
page: result.page,
|
||||
rows: result.rows.map((row) => {
|
||||
return { ...row.toJSON(), user: user.toJSON() }
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
7
src/services/guilds/[guildId]/members/index.ts
Normal file
7
src/services/guilds/[guildId]/members/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getMembersRouter } from './get'
|
||||
|
||||
export const guildsMembersRouter = Router()
|
||||
|
||||
guildsMembersRouter.use('/', getMembersRouter)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user