2
1
mirror of https://github.com/Thream/api.git synced 2024-07-04 03:40:12 +02:00

chore: initial commit

This commit is contained in:
Divlo 2021-10-24 04:06:16 +02:00
commit 714cc643ba
No known key found for this signature in database
GPG Key ID: 6F24DA54DA3967CF
260 changed files with 40783 additions and 0 deletions

1
.commitlintrc.json Normal file
View File

@ -0,0 +1 @@
{ "extends": ["@commitlint/config-conventional"] }

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.vscode
.git
build
coverage
node_modules
tmp
temp
**/__test__/**

11
.editorconfig Normal file
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
_

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:commit -- --edit

8
.husky/pre-commit Executable file
View 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
View File

@ -0,0 +1,7 @@
{
"default": true,
"MD013": false,
"MD024": false,
"MD033": false,
"MD041": false
}

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

123
CONTRIBUTING.md Normal file
View 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
View 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
View 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
View 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
View 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)

View 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
View 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
View 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>

View 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."
}
}

View 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."
}
}

View 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."
}
}

View 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

File diff suppressed because it is too large Load Diff

146
package.json Normal file
View 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"
}
}

View 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)
})
})

View 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
View 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()
})

View 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'])
})

View 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 }
}

View 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 []
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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');

View File

@ -0,0 +1,6 @@
components:
securitySchemes:
bearerAuth:
type: 'http'
scheme: 'bearer'
bearerFormat: 'JWT'

View 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'

View 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

View 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'

View 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'

View 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])
)
})
})

View 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']))
})
})

View 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 })
}
)

View 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'

View File

@ -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'

View File

@ -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()
})
})

View File

@ -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']))
})
})

View 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() }
})
})
}
)

View 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)

View 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 })
}
)

View 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 })
}
)

View 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'

View 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
}
}

View 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)

View 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))

View 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'

View 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'

View 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'

View 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']))
})
})

View 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']))
})
})

View 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 })
])
)
})
})

View 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'

View 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'

View 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)
})
})

View 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']))
})
})

View 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)
}
)

View 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)

View 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 })
}
)

View 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 })
}
)

View 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 })
}
)

View 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)

View 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'

View 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'

View File

@ -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']))
})
})

View 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']))
})
})

View 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)
}
)

View 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)

View 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 })
}
)

View 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'

View 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()
})
})

View 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() }
})
})
}
)

View 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