Repository: gothinkster/node-express-prisma-v1-official-app
Branch: main
Commit: 6ac99ea5aead
Files: 52
Total size: 108.0 KB
Directory structure:
gitextract_bhrnvjgz/
├── .eslintrc.json
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── ci.yaml
├── .gitignore
├── .husky/
│ ├── .gitignore
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── Procfile
├── README.md
├── app.json
├── docs/
│ └── swagger.json
├── jest.config.js
├── package.json
├── prisma/
│ ├── migrations/
│ │ ├── 20210924222830_initial/
│ │ │ └── migration.sql
│ │ ├── 20211001195651_implicit_articles/
│ │ │ └── migration.sql
│ │ ├── 20211105082430_api_url/
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ ├── prisma-client.ts
│ └── schema.prisma
├── src/
│ ├── controllers/
│ │ ├── article.controller.ts
│ │ ├── auth.controller.ts
│ │ ├── profile.controller.ts
│ │ └── tag.controller.ts
│ ├── index.ts
│ ├── models/
│ │ ├── article.model.ts
│ │ ├── comment.model.ts
│ │ ├── http-exception.model.ts
│ │ ├── profile.model.ts
│ │ ├── register-input.model.ts
│ │ ├── registered-user.model.ts
│ │ ├── tag.model.ts
│ │ └── user.model.ts
│ ├── routes/
│ │ └── routes.ts
│ ├── services/
│ │ ├── article.service.ts
│ │ ├── auth.service.ts
│ │ ├── profile.service.ts
│ │ └── tag.service.ts
│ └── utils/
│ ├── auth.ts
│ ├── profile.utils.ts
│ ├── token.utils.ts
│ └── user-request.d.ts
├── tests/
│ ├── prisma-mock.ts
│ ├── services/
│ │ ├── article.service.test.ts
│ │ ├── auth.service.test.ts
│ │ ├── profile.service.test.ts
│ │ └── tag.service.test.ts
│ └── utils/
│ └── profile.utils.test.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"env": {
"browser": true,
"es2021": true,
"jest": true
},
"extends": ["airbnb-base", "prettier"],
"globals": {
"NodeJS": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never"
}
],
"no-underscore-dangle": ["error", { "allow": ["_count"] }],
"no-console": ["error", { "allow": ["info"] }],
"import/no-extraneous-dependencies": ["error", { "devDependencies": ["tests/prisma-mock.ts"] }]
},
"settings": {
"import/resolver": {
"node": {
"paths": ["src"],
"extensions": [".js", ".ts", ".d.ts", ".tsx"]
}
}
},
"ignorePatterns": ["*.d.ts"]
}
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: github-actions
directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: '/'
schedule:
interval: weekly
open-pull-requests-limit: 10
================================================
FILE: .github/workflows/ci.yaml
================================================
name: CI
on:
push:
branches: [main]
pull_request:
branches:
- '**'
jobs:
run_tests:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: Cache node modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install dependencies
run: npm ci --no-audit --prefer-offline --progress=false
- name: Check prettier
run: npm run prettier:check
- name: Check ESLinter
run: npm run lint:check
- name: Check unit tests
run: npm run test --ci --lastCommit --maxWorkers=50%
env:
CI: true
================================================
FILE: .gitignore
================================================
/node_modules
/dist
# Keep environment variables out of version control
.env
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
================================================
FILE: .husky/.gitignore
================================================
_
================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
================================================
FILE: .prettierignore
================================================
/dist
================================================
FILE: .prettierrc.json
================================================
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "avoid"
}
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@thinkster.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to RealWorld
We would love for you to contribute to RealWorld and help make it even better than it is
today! As a contributor, here are the guidelines we would like you to follow:
- [Code of Conduct](#coc)
- [Question or Problem?](#question)
- [Issues and Bugs](#issue)
- [Feature Requests](#feature)
- [Submission Guidelines](#submit)
- [Coding Rules](#rules)
- [Commit Message Guidelines](#commit)
## <a name="coc"></a> Code of Conduct
Help us keep RealWorld open and inclusive. Please read and follow our [Code of Conduct][coc].
## <a name="question"></a> Got a Question or Problem?
Do not open issues for general support questions as we want to keep GitHub issues for bug reports and feature requests.
For open discussions, we encourage you to use the [Github Discussions][github-discussions] channels of the RealWorld repository.
## <a name="issue"></a> Found a Bug?
If you find a bug in the project, you can help us by
[submitting an issue][github-issue] to our [GitHub Repository][github]. Even better, you can
[submit a Pull Request](#submit-pr) with a fix.
## <a name="feature"></a> Missing a Feature?
This repository follows the RealWorld [specs][github-spec].
Please open feature requests on the RealWorld [repository][github-feature].
## <a name="submit"></a> Submission Guidelines
### <a name="submit-issue"></a> Submitting an Issue
Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available.
You can file new issues by selecting from our [new issue templates][github-choose] and filling out the issue template.
### <a name="submit-pr"></a> Submitting a Pull Request (PR)
Before you submit your Pull Request (PR) consider the following guidelines:
1. Search [GitHub](https://github.com/gothinkster/node-express-prisma-v1-official-app/pulls) for an open or closed PR
that relates to your submission. You don't want to duplicate effort.
1. Be sure that an issue describes the problem you're fixing, or documents the design for the feature you'd like to add.
Discussing the design up front helps to ensure that we're ready to accept your work.
1. Fork the gothinkster/realworld repo.
1. Make your changes in a new git branch:
```bash
git checkout -b my-fix-branch main
```
1. Create your patch.
1. Commit your changes using a descriptive commit message that follows our
[commit message conventions](#commit).
1. Push your branch to GitHub:
```bash
git push origin my-fix-branch
```
1. In GitHub, send a pull request to `node-express-prisma-v1-official-app:main`.
- If we suggest changes then:
- Make the required updates.
- Rebase your branch and force push to your GitHub repository (this will update your Pull Request):
```bash
git rebase main -i
git push -f
```
That's it! Thank you for your contribution!
#### After your pull request is merged
After your pull request is merged, you can safely delete your branch and pull the changes
from the main (upstream) repository:
- Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows:
```bash
git push origin --delete my-fix-branch
```
- Check out the main branch:
```bash
git checkout main -f
```
- Delete the local branch:
```bash
git branch -D my-fix-branch
```
- Update your main with the latest upstream version:
```bash
git pull --ff upstream main
```
## <a name="commit"></a> Commit Message Guidelines
> These guidelines have been added to the project starting from <include date>
We have very precise rules over how our git commit messages can be formatted. This leads to **more
readable messages** that are easy to follow when looking through the **project history**.
### Commit Message Format
Each commit message consists of a **header**, a **body** and a **footer**. The header has a special
format that includes a **type**, a **scope** and a **subject**:
```
<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>
```
The **header** is mandatory and the **scope** of the header is optional.
Any line of the commit message cannot be longer 100 characters! This allows the message to be easier
to read on GitHub as well as in various git tools.
The footer should contain a [closing reference to an issue](https://help.github.com/articles/closing-issues-via-commit-messages/) if any.
Samples:
```
docs(changelog): update changelog to beta.5
```
### Type
Must be one of the following:
- **docs**: Documentation only changes
- **feat**: A new feature
- **fix**: A bug fix
### Scope
The scope should be the name of the npm package affected (as perceived by the person reading the changelog generated from commit messages).
The following is the list of supported scopes:
- **specs**
- **project**
### Subject
The subject contains a succinct description of the change:
- use the imperative, present tense: "change" not "changed" nor "changes"
- don't capitalize the first letter
- no dot (.) at the end
### Body
Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes".
The body should include the motivation for the change and contrast this with previous behavior.
### Footer
The footer should contain any information about **Breaking Changes** and is also the place to
reference GitHub issues that this commit **Closes**.
**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this.
Samples :
```
Close #394
```
```
BREAKING CHANGE:
change login route to /users/login
```
[coc]: https://github.com/gothinkster/node-express-prisma-v1-official-app/blob/main/CODE_OF_CONDUCT.md
[github]: https://github.com/gothinkster/node-express-prisma-v1-official-app
[github-issue]: https://github.com/gothinkster/node-express-prisma-v1-official-app/issues/new?assignees=&labels=bug&template=---bug-report.md&title=
[github-feature]: https://github.com/gothinkster/realworld/issues/new?assignees=&labels=enhancement&template=---feature-request.md&title=
[github-choose]: https://github.com/gothinkster/node-express-prisma-v1-official-app/issues/new/choose
[github-discussions]: https://github.com/gothinkster/realworld/discussions
[github-spec]: https://github.com/gothinkster/realworld/tree/master/spec
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021
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.
================================================
FILE: Procfile
================================================
web: npm start
release: npx prisma migrate deploy
================================================
FILE: README.md
================================================
# 
> Official NodeJS codebase that adheres to the [RealWorld](https://gothinkster.github.io/realworld/docs/specs/backend-specs/introduction) API spec.
This repo is functionality complete.
# Deploy to Heroku
[](https://heroku.com/deploy)
# Getting started
### Clone the repository
run `git clone https://github.com/gothinkster/node-express-prisma-v1-official-app.git`
### Install the dependancies
> [NodeJS](https://nodejs.dev/) is required
```
cd node-express-prisma-v1-official-app
npm install
```
### Download pgAdmin for PostgreSQL
[PostgreSQL](https://www.postgresql.org/download/) downloads page
### Create a server
run **pgAdmin**
create a server (Object/Create/Server)
required fields:
- name
- HOST name/address
### Connect the created server
create a _.env_ file at the root of the project
populate it with the url of your database
```
DATABASE_URL="postgresql://<username>:<password>@<host_name>:<port>/<database_name>?schema=public"
```
### Run the project locally
run `npm run dev`
## Advanced usage
### Prisma
### Format the Prisma schema
```bash
npm run prisma:format
```
### Migrate the SQL schema
```bash
prisma migrate dev --name added_job_title
```
### Update the Prisma Client
```bash
npm run prisma:generate
```
_with watch option_
```bash
npm run prisma:generate:watch
```
### Seed the database
```bash
npm run prisma:seed
```
### Launch Prisma Studio
```bash
npm run prisma:studio
```
### Reset the database
- Drop the database
- Create a new database
- Apply migrations
- Seed the database with data
```bash
npm run prisma:reset
```
================================================
FILE: app.json
================================================
{
"name": "RealWorld API",
"description": "Node / Express / Prisma API for RealWorld project",
"keywords": ["node", "express", "prisma", "realworld"],
"website": "https://gothinkster.github.io/realworld/",
"repository": "https://github.com/gothinkster/realworld",
"addons": ["heroku-postgresql"],
"env": {
"JWT_SECRET": {
"description": "A secret key for verifying authenticated users",
"generator": "secret"
}
}
}
================================================
FILE: docs/swagger.json
================================================
{
"swagger": "2.0",
"info": {
"description": "Conduit API",
"version": "1.0.0",
"title": "Conduit API",
"contact": {
"name": "RealWorld",
"url": "https://realworld.io"
},
"license": {
"name": "MIT License",
"url": "https://opensource.org/licenses/MIT"
}
},
"basePath": "/api",
"schemes": ["https", "http"],
"produces": ["application/json"],
"consumes": ["application/json"],
"securityDefinitions": {
"Token": {
"description": "For accessing the protected API resources, you must have received a a valid JWT token after registering or logging in. This JWT token must then be used for all protected resources by passing it in via the 'Authorization' header.\n\nA JWT token is generated by the API by either registering via /users or logging in via /users/login.\n\nThe following format must be in the 'Authorization' header :\n\n Token: xxxxxx.yyyyyyy.zzzzzz\n \n",
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
},
"paths": {
"/users/login": {
"post": {
"summary": "Existing user login",
"description": "Login for existing user",
"tags": ["User and Authentication"],
"operationId": "Login",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"description": "Credentials to use",
"schema": {
"$ref": "#/definitions/LoginUserRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/UserResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/users": {
"post": {
"summary": "Register a new user",
"description": "Register a new user",
"tags": ["User and Authentication"],
"operationId": "CreateUser",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"description": "Details of the new user to register",
"schema": {
"$ref": "#/definitions/NewUserRequest"
}
}
],
"responses": {
"201": {
"description": "OK",
"schema": {
"$ref": "#/definitions/UserResponse"
}
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/user": {
"get": {
"summary": "Get current user",
"description": "Gets the currently logged-in user",
"tags": ["User and Authentication"],
"security": [
{
"Token": []
}
],
"operationId": "GetCurrentUser",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/UserResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"put": {
"summary": "Update current user",
"description": "Updated user information for current user",
"tags": ["User and Authentication"],
"security": [
{
"Token": []
}
],
"operationId": "UpdateCurrentUser",
"parameters": [
{
"name": "body",
"in": "body",
"required": true,
"description": "User details to update. At least **one** field is required.",
"schema": {
"$ref": "#/definitions/UpdateUserRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/UserResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/profiles/{username}": {
"get": {
"summary": "Get a profile",
"description": "Get a profile of a user of the system. Auth is optional",
"tags": ["Profile"],
"operationId": "GetProfileByUsername",
"parameters": [
{
"name": "username",
"in": "path",
"description": "Username of the profile to get",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ProfileResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/profiles/{username}/follow": {
"post": {
"summary": "Follow a user",
"description": "Follow a user by username",
"tags": ["Profile"],
"security": [
{
"Token": []
}
],
"operationId": "FollowUserByUsername",
"parameters": [
{
"name": "username",
"in": "path",
"description": "Username of the profile you want to follow",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ProfileResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"delete": {
"summary": "Unfollow a user",
"description": "Unfollow a user by username",
"tags": ["Profile"],
"security": [
{
"Token": []
}
],
"operationId": "UnfollowUserByUsername",
"parameters": [
{
"name": "username",
"in": "path",
"description": "Username of the profile you want to unfollow",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/ProfileResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles/feed": {
"get": {
"summary": "Get recent articles from users you follow",
"description": "Get most recent articles from users you follow. Use query parameters to limit. Auth is required",
"tags": ["Articles"],
"security": [
{
"Token": []
}
],
"operationId": "GetArticlesFeed",
"parameters": [
{
"name": "limit",
"in": "query",
"description": "Limit number of articles returned (default is 20)",
"required": false,
"default": 20,
"type": "integer"
},
{
"name": "offset",
"in": "query",
"description": "Offset/skip number of articles (default is 0)",
"required": false,
"default": 0,
"type": "integer"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/MultipleArticlesResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles": {
"get": {
"summary": "Get recent articles globally",
"description": "Get most recent articles globally. Use query parameters to filter results. Auth is optional",
"tags": ["Articles"],
"operationId": "GetArticles",
"parameters": [
{
"name": "tag",
"in": "query",
"description": "Filter by tag",
"required": false,
"type": "string"
},
{
"name": "author",
"in": "query",
"description": "Filter by author (username)",
"required": false,
"type": "string"
},
{
"name": "favorited",
"in": "query",
"description": "Filter by favorites of a user (username)",
"required": false,
"type": "string"
},
{
"name": "limit",
"in": "query",
"description": "Limit number of articles returned (default is 20)",
"required": false,
"default": 20,
"type": "integer"
},
{
"name": "offset",
"in": "query",
"description": "Offset/skip number of articles (default is 0)",
"required": false,
"default": 0,
"type": "integer"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/MultipleArticlesResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"post": {
"summary": "Create an article",
"description": "Create an article. Auth is required",
"tags": ["Articles"],
"security": [
{
"Token": []
}
],
"operationId": "CreateArticle",
"parameters": [
{
"name": "article",
"in": "body",
"required": true,
"description": "Article to create",
"schema": {
"$ref": "#/definitions/NewArticleRequest"
}
}
],
"responses": {
"201": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleArticleResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles/{slug}": {
"get": {
"summary": "Get an article",
"description": "Get an article. Auth not required",
"tags": ["Articles"],
"operationId": "GetArticle",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article to get",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleArticleResponse"
}
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"put": {
"summary": "Update an article",
"description": "Update an article. Auth is required",
"tags": ["Articles"],
"security": [
{
"Token": []
}
],
"operationId": "UpdateArticle",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article to update",
"type": "string"
},
{
"name": "article",
"in": "body",
"required": true,
"description": "Article to update",
"schema": {
"$ref": "#/definitions/UpdateArticleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleArticleResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"delete": {
"summary": "Delete an article",
"description": "Delete an article. Auth is required",
"tags": ["Articles"],
"security": [
{
"Token": []
}
],
"operationId": "DeleteArticle",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article to delete",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles/{slug}/comments": {
"get": {
"summary": "Get comments for an article",
"description": "Get the comments for an article. Auth is optional",
"tags": ["Comments"],
"operationId": "GetArticleComments",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article that you want to get comments for",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/MultipleCommentsResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"post": {
"summary": "Create a comment for an article",
"description": "Create a comment for an article. Auth is required",
"tags": ["Comments"],
"security": [
{
"Token": []
}
],
"operationId": "CreateArticleComment",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article that you want to create a comment for",
"type": "string"
},
{
"name": "comment",
"in": "body",
"required": true,
"description": "Comment you want to create",
"schema": {
"$ref": "#/definitions/NewCommentRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleCommentResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles/{slug}/comments/{id}": {
"delete": {
"summary": "Delete a comment for an article",
"description": "Delete a comment for an article. Auth is required",
"tags": ["Comments"],
"security": [
{
"Token": []
}
],
"operationId": "DeleteArticleComment",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article that you want to delete a comment for",
"type": "string"
},
{
"name": "id",
"in": "path",
"required": true,
"description": "ID of the comment you want to delete",
"type": "integer"
}
],
"responses": {
"200": {
"description": "OK"
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/articles/{slug}/favorite": {
"post": {
"summary": "Favorite an article",
"description": "Favorite an article. Auth is required",
"tags": ["Favorites"],
"security": [
{
"Token": []
}
],
"operationId": "CreateArticleFavorite",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article that you want to favorite",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleArticleResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
},
"delete": {
"summary": "Unfavorite an article",
"description": "Unfavorite an article. Auth is required",
"tags": ["Favorites"],
"security": [
{
"Token": []
}
],
"operationId": "DeleteArticleFavorite",
"parameters": [
{
"name": "slug",
"in": "path",
"required": true,
"description": "Slug of the article that you want to unfavorite",
"type": "string"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/SingleArticleResponse"
}
},
"401": {
"description": "Unauthorized"
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
},
"/tags": {
"get": {
"summary": "Get tags",
"description": "Get tags. Auth not required",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/TagsResponse"
}
},
"422": {
"description": "Unexpected error",
"schema": {
"$ref": "#/definitions/GenericErrorModel"
}
}
}
}
}
},
"definitions": {
"LoginUser": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"password": {
"type": "string",
"format": "password"
}
},
"required": ["email", "password"]
},
"LoginUserRequest": {
"type": "object",
"properties": {
"user": {
"$ref": "#/definitions/LoginUser"
}
},
"required": ["user"]
},
"NewUser": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"email": {
"type": "string"
},
"password": {
"type": "string",
"format": "password"
}
},
"required": ["username", "email", "password"]
},
"NewUserRequest": {
"type": "object",
"properties": {
"user": {
"$ref": "#/definitions/NewUser"
}
},
"required": ["user"]
},
"User": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"token": {
"type": "string"
},
"username": {
"type": "string"
},
"bio": {
"type": "string"
},
"image": {
"type": "string"
}
},
"required": ["email", "token", "username", "bio", "image"]
},
"UserResponse": {
"type": "object",
"properties": {
"user": {
"$ref": "#/definitions/User"
}
},
"required": ["user"]
},
"UpdateUser": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"token": {
"type": "string"
},
"username": {
"type": "string"
},
"bio": {
"type": "string"
},
"image": {
"type": "string"
}
}
},
"UpdateUserRequest": {
"type": "object",
"properties": {
"user": {
"$ref": "#/definitions/UpdateUser"
}
},
"required": ["user"]
},
"ProfileResponse": {
"type": "object",
"properties": {
"profile": {
"$ref": "#/definitions/Profile"
}
},
"required": ["profile"]
},
"Profile": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"bio": {
"type": "string"
},
"image": {
"type": "string"
},
"following": {
"type": "boolean"
}
},
"required": ["username", "bio", "image", "following"]
},
"Article": {
"type": "object",
"properties": {
"slug": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"body": {
"type": "string"
},
"tagList": {
"type": "array",
"items": {
"type": "string"
}
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"favorited": {
"type": "boolean"
},
"favoritesCount": {
"type": "integer"
},
"author": {
"$ref": "#/definitions/Profile"
}
},
"required": [
"slug",
"title",
"description",
"body",
"tagList",
"createdAt",
"updatedAt",
"favorited",
"favoritesCount",
"author"
]
},
"SingleArticleResponse": {
"type": "object",
"properties": {
"article": {
"$ref": "#/definitions/Article"
}
},
"required": ["article"]
},
"MultipleArticlesResponse": {
"type": "object",
"properties": {
"articles": {
"type": "array",
"items": {
"$ref": "#/definitions/Article"
}
},
"articlesCount": {
"type": "integer"
}
},
"required": ["articles", "articlesCount"]
},
"NewArticle": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"body": {
"type": "string"
},
"tagList": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["title", "description", "body"]
},
"NewArticleRequest": {
"type": "object",
"properties": {
"article": {
"$ref": "#/definitions/NewArticle"
}
},
"required": ["article"]
},
"UpdateArticle": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"body": {
"type": "string"
}
}
},
"UpdateArticleRequest": {
"type": "object",
"properties": {
"article": {
"$ref": "#/definitions/UpdateArticle"
}
},
"required": ["article"]
},
"Comment": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"updatedAt": {
"type": "string",
"format": "date-time"
},
"body": {
"type": "string"
},
"author": {
"$ref": "#/definitions/Profile"
}
},
"required": ["id", "createdAt", "updatedAt", "body", "author"]
},
"SingleCommentResponse": {
"type": "object",
"properties": {
"comment": {
"$ref": "#/definitions/Comment"
}
},
"required": ["comment"]
},
"MultipleCommentsResponse": {
"type": "object",
"properties": {
"comments": {
"type": "array",
"items": {
"$ref": "#/definitions/Comment"
}
}
},
"required": ["comments"]
},
"NewComment": {
"type": "object",
"properties": {
"body": {
"type": "string"
}
},
"required": ["body"]
},
"NewCommentRequest": {
"type": "object",
"properties": {
"comment": {
"$ref": "#/definitions/NewComment"
}
},
"required": ["comment"]
},
"TagsResponse": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["tags"]
},
"GenericErrorModel": {
"type": "object",
"properties": {
"errors": {
"type": "object",
"properties": {
"body": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["body"]
}
},
"required": ["errors"]
}
}
}
================================================
FILE: jest.config.js
================================================
module.exports = {
clearMocks: true,
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/prisma-mock.ts'],
};
================================================
FILE: package.json
================================================
{
"name": "express-prisma-realworld-official-app",
"version": "1.0.0",
"description": "Node.js, Express.js & Prisma RealWorld official app",
"main": "index.js",
"scripts": {
"postinstall": "tsc",
"test": "jest -i",
"dev": "ts-node-dev --respawn --pretty --transpile-only src/index.ts dev",
"start": "node dist/src/index.js",
"prisma:migrate": "prisma migrate dev --skip-seed",
"prisma:format": "prisma format",
"prisma:generate": "prisma generate",
"prisma:generate:watch": "prisma generate --watch",
"prisma:seed": "prisma db seed --preview-feature",
"prisma:studio": "prisma studio",
"prisma:reset": "prisma migrate reset",
"prettier:write": "npx prettier --write .",
"prettier:check": "npx prettier --check .",
"lint:check": "npx eslint src/**/*.ts",
"lint:fix": "npx eslint --fix src/**/*.ts",
"prepare": "husky install"
},
"keywords": [
"node",
"express",
"prisma",
"realworld"
],
"author": {
"name": "Gerome Grignon",
"email": "gerome.grignon.lp2@gmail.com"
},
"license": "MIT",
"dependencies": {
"@prisma/client": "^2.29.1",
"@types/swagger-ui-express": "^4.1.3",
"bcryptjs": "^2.4.3",
"body-parser": "^1.19.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-jwt": "^6.1.0",
"jsonwebtoken": "^8.5.1",
"slugify": "^1.6.0",
"swagger-ui-express": "^4.1.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.12",
"@types/cron": "^1.7.3",
"@types/express": "^4.17.13",
"@types/express-rate-limit": "^5.1.3",
"@types/jest": "^27.0.1",
"@types/jsonwebtoken": "^8.5.5",
"@types/node": "^15.14.9",
"@typescript-eslint/eslint-plugin": "^4.31.0",
"@typescript-eslint/parser": "^4.31.0",
"eslint": "^7.32.0",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.24.2",
"husky": "^7.0.2",
"jest": "^27.1.0",
"jest-mock-extended": "^2.0.4",
"lint-staged": "^11.1.2",
"prettier": "2.4.0",
"prisma": "^2.29.1",
"ts-jest": "^27.0.5",
"ts-node-dev": "^1.1.8",
"typescript": "^4.4.2"
},
"lint-staged": {
"*.ts": [
"npm run prisma:format",
"npm run lint:fix",
"npm run prettier:write",
"git add"
]
}
}
================================================
FILE: prisma/migrations/20210924222830_initial/migration.sql
================================================
-- CreateTable
CREATE TABLE "Article" (
"id" SERIAL NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ArticleTags" (
"articleId" INTEGER NOT NULL,
"tagId" INTEGER NOT NULL,
PRIMARY KEY ("articleId","tagId")
);
-- CreateTable
CREATE TABLE "Comment" (
"id" SERIAL NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"body" TEXT NOT NULL,
"articleId" INTEGER NOT NULL,
"authorId" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tag" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"image" TEXT DEFAULT E'https://realworld-temp-api.herokuapp.com/images/smiley-cyrus.jpeg',
"bio" TEXT,
PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_UserFavorites" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateTable
CREATE TABLE "_UserFollows" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Article.slug_unique" ON "Article"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User.username_unique" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "_UserFavorites_AB_unique" ON "_UserFavorites"("A", "B");
-- CreateIndex
CREATE INDEX "_UserFavorites_B_index" ON "_UserFavorites"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B");
-- CreateIndex
CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B");
-- AddForeignKey
ALTER TABLE "Article" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ArticleTags" ADD FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comment" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserFavorites" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserFollows" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: prisma/migrations/20211001195651_implicit_articles/migration.sql
================================================
/*
Warnings:
- You are about to drop the `ArticleTags` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[name]` on the table `Tag` will be added. If there are existing duplicate values, this will fail.
*/
-- DropForeignKey
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_articleId_fkey";
-- DropForeignKey
ALTER TABLE "ArticleTags" DROP CONSTRAINT "ArticleTags_tagId_fkey";
-- DropTable
DROP TABLE "ArticleTags";
-- CreateTable
CREATE TABLE "_ArticleToTag" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B");
-- CreateIndex
CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name");
-- AddForeignKey
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
================================================
FILE: prisma/migrations/20211105082430_api_url/migration.sql
================================================
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "image" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg';
================================================
FILE: prisma/migrations/migration_lock.toml
================================================
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
================================================
FILE: prisma/prisma-client.ts
================================================
import { PrismaClient } from '@prisma/client';
// add prisma to the NodeJS global type
// TODO : downgraded @types/node to 15.14.1 to avoid error on NodeJS.Global
interface CustomNodeJsGlobal extends NodeJS.Global {
prisma: PrismaClient;
}
// Prevent multiple instances of Prisma Client in development
declare const global: CustomNodeJsGlobal;
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV === 'development') {
global.prisma = prisma;
}
export default prisma;
================================================
FILE: prisma/schema.prisma
================================================
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["orderByAggregateGroup", "selectRelationCount", "referentialActions"]
}
model Article {
id Int @id @default(autoincrement())
slug String @unique
title String
description String
body String
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
tagList Tag[]
author User @relation("UserArticles", fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
favoritedBy User[] @relation("UserFavorites", references: [id])
comments Comment[]
}
model Comment {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
body String
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
articleId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
articles Article[]
}
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String
image String? @default("https://api.realworld.io/images/smiley-cyrus.jpeg")
bio String?
articles Article[] @relation("UserArticles")
favorites Article[] @relation("UserFavorites", references: [id])
followedBy User[] @relation("UserFollows", references: [id])
following User[] @relation("UserFollows", references: [id])
comments Comment[]
}
================================================
FILE: src/controllers/article.controller.ts
================================================
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../utils/auth';
import {
addComment,
createArticle,
deleteArticle,
deleteComment,
favoriteArticle,
getArticle,
getArticles,
getCommentsByArticle,
getFeed,
unfavoriteArticle,
updateArticle,
} from '../services/article.service';
const router = Router();
/**
* Get paginated articles
* @auth optional
* @route {GET} /articles
* @queryparam offset number of articles dismissed from the first one
* @queryparam limit number of articles returned
* @queryparam tag
* @queryparam author
* @queryparam favorited
* @returns articles: list of articles
*/
router.get('/articles', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await getArticles(req.query, req.user?.username);
res.json(result);
} catch (error) {
next(error);
}
});
/**
* Get paginated feed articles
* @auth required
* @route {GET} /articles/feed
* @returns articles list of articles
*/
router.get(
'/articles/feed',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await getFeed(
Number(req.query.offset),
Number(req.query.limit),
req.user?.username as string,
);
res.json(result);
} catch (error) {
next(error);
}
},
);
/**
* Create article
* @route {POST} /articles
* @bodyparam title
* @bodyparam description
* @bodyparam body
* @bodyparam tagList list of tags
* @returns article created article
*/
router.post('/articles', auth.required, async (req: Request, res: Response, next: NextFunction) => {
try {
const article = await createArticle(req.body.article, req.user?.username as string);
res.json({ article });
} catch (error) {
next(error);
}
});
/**
* Get unique article
* @auth optional
* @route {GET} /article/:slug
* @param slug slug of the article (based on the title)
* @returns article
*/
router.get(
'/articles/:slug',
auth.optional,
async (req: Request, res: Response, next: NextFunction) => {
try {
const article = await getArticle(req.params.slug, req.user?.username as string);
res.json({ article });
} catch (error) {
next(error);
}
},
);
/**
* Update article
* @auth required
* @route {PUT} /articles/:slug
* @param slug slug of the article (based on the title)
* @bodyparam title new title
* @bodyparam description new description
* @bodyparam body new content
* @returns article updated article
*/
router.put(
'/articles/:slug',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const article = await updateArticle(
req.body.article,
req.params.slug,
req.user?.username as string,
);
res.json({ article });
} catch (error) {
next(error);
}
},
);
/**
* Delete article
* @auth required
* @route {DELETE} /article/:id
* @param slug slug of the article
*/
router.delete(
'/articles/:slug',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteArticle(req.params.slug);
res.sendStatus(204);
} catch (error) {
next(error);
}
},
);
/**
* Get comments from an article
* @auth optional
* @route {GET} /articles/:slug/comments
* @param slug slug of the article (based on the title)
* @returns comments list of comments
*/
router.get(
'/articles/:slug/comments',
auth.optional,
async (req: Request, res: Response, next: NextFunction) => {
try {
const comments = await getCommentsByArticle(req.params.slug, req.user?.username);
res.json({ comments });
} catch (error) {
next(error);
}
},
);
/**
* Add comment to article
* @auth required
* @route {POST} /articles/:slug/comments
* @param slug slug of the article (based on the title)
* @bodyparam body content of the comment
* @returns comment created comment
*/
router.post(
'/articles/:slug/comments',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const comment = await addComment(
req.body.comment.body,
req.params.slug,
req.user?.username as string,
);
res.json({ comment });
} catch (error) {
next(error);
}
},
);
/**
* Delete comment
* @auth required
* @route {DELETE} /articles/:slug/comments/:id
* @param slug slug of the article (based on the title)
* @param id id of the comment
*/
router.delete(
'/articles/:slug/comments/:id',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteComment(Number(req.params.id), req.user?.username as string);
res.sendStatus(204);
} catch (error) {
next(error);
}
},
);
/**
* Favorite article
* @auth required
* @route {POST} /articles/:slug/favorite
* @param slug slug of the article (based on the title)
* @returns article favorited article
*/
router.post(
'/articles/:slug/favorite',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const article = await favoriteArticle(req.params.slug, req.user?.username as string);
res.json({ article });
} catch (error) {
next(error);
}
},
);
/**
* Unfavorite article
* @auth required
* @route {DELETE} /articles/:slug/favorite
* @param slug slug of the article (based on the title)
* @returns article unfavorited article
*/
router.delete(
'/articles/:slug/favorite',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const article = await unfavoriteArticle(req.params.slug, req.user?.username as string);
res.json({ article });
} catch (error) {
next(error);
}
},
);
export default router;
================================================
FILE: src/controllers/auth.controller.ts
================================================
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../utils/auth';
import { createUser, getCurrentUser, login, updateUser } from '../services/auth.service';
const router = Router();
/**
* Create an user
* @auth none
* @route {POST} /users
* @bodyparam user User
* @returns user User
*/
router.post('/users', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await createUser(req.body.user);
res.json({ user });
} catch (error) {
next(error);
}
});
/**
* Login
* @auth none
* @route {POST} /users/login
* @bodyparam user User
* @returns user User
*/
router.post('/users/login', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await login(req.body.user);
res.json({ user });
} catch (error) {
next(error);
}
});
/**
* Get current user
* @auth required
* @route {GET} /user
* @returns user User
*/
router.get('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await getCurrentUser(req.user?.username as string);
res.json({ user });
} catch (error) {
next(error);
}
});
/**
* Update user
* @auth required
* @route {PUT} /user
* @bodyparam user User
* @returns user User
*/
router.put('/user', auth.required, async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await updateUser(req.body.user, req.user?.username as string);
res.json({ user });
} catch (error) {
next(error);
}
});
export default router;
================================================
FILE: src/controllers/profile.controller.ts
================================================
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../utils/auth';
import { followUser, getProfile, unfollowUser } from '../services/profile.service';
const router = Router();
/**
* Get profile
* @auth optional
* @route {GET} /profiles/:username
* @param username string
* @returns profile
*/
router.get(
'/profiles/:username',
auth.optional,
async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await getProfile(req.params?.username, req.user?.username as string);
res.json({ profile });
} catch (error) {
next(error);
}
},
);
/**
* Follow user
* @auth required
* @route {POST} /profiles/:username/follow
* @param username string
* @returns profile
*/
router.post(
'/profiles/:username/follow',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await followUser(req.params?.username, req.user?.username as string);
res.json({ profile });
} catch (error) {
next(error);
}
},
);
/**
* Unfollow user
* @auth required
* @route {DELETE} /profiles/:username/follow
* @param username string
* @returns profiles
*/
router.delete(
'/profiles/:username/follow',
auth.required,
async (req: Request, res: Response, next: NextFunction) => {
try {
const profile = await unfollowUser(req.params.username, req.user?.username as string);
res.json({ profile });
} catch (error) {
next(error);
}
},
);
export default router;
================================================
FILE: src/controllers/tag.controller.ts
================================================
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../utils/auth';
import getTags from '../services/tag.service';
const router = Router();
/**
* Get top 10 popular tags
* @auth optional
* @route {GET} /api/tags
* @returns tags list of tag names
*/
router.get('/tags', auth.optional, async (req: Request, res: Response, next: NextFunction) => {
try {
const tags = await getTags(req.user?.username);
res.json({ tags });
} catch (error) {
next(error);
}
});
export default router;
================================================
FILE: src/index.ts
================================================
import express, { NextFunction, Request, Response } from 'express';
import cors from 'cors';
import swaggerUi from 'swagger-ui-express';
import bodyParser from 'body-parser';
import routes from './routes/routes';
import HttpException from './models/http-exception.model';
import swaggerDocument from '../docs/swagger.json';
const app = express();
/**
* App Configuration
*/
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(routes);
// Serves images
app.use(express.static('public'));
app.get('/', (req: Request, res: Response) => {
res.json({ status: 'API is running on /api' });
});
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.get('/api-docs', (req: Request, res: Response) => {
res.json({
swagger:
'the API documentation is now available on https://realworld-temp-api.herokuapp.com/api',
});
});
/* eslint-disable */
app.use((err: Error | HttpException, req: Request, res: Response, next: NextFunction) => {
// @ts-ignore
if (err && err.name === 'UnauthorizedError') {
return res.status(401).json({
status: 'error',
message: 'missing authorization credentials',
});
// @ts-ignore
} else if (err && err.errorCode) {
// @ts-ignore
res.status(err.errorCode).json(err.message);
} else if (err) {
res.status(500).json(err.message);
}
});
/**
* Server activation
*/
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.info(`server up on port ${PORT}`);
});
================================================
FILE: src/models/article.model.ts
================================================
import { Comment } from './comment.model';
export interface Article {
id: number;
title: string;
slug: string;
description: string;
comments: Comment[];
favorited: boolean;
}
================================================
FILE: src/models/comment.model.ts
================================================
import { Article } from './article.model';
export interface Comment {
id: number;
createdAt: Date;
updatedAt: Date;
body: string;
article?: Article;
}
================================================
FILE: src/models/http-exception.model.ts
================================================
class HttpException extends Error {
errorCode: number;
constructor(errorCode: number, public readonly message: string | any) {
super(message);
this.errorCode = errorCode;
}
}
export default HttpException;
================================================
FILE: src/models/profile.model.ts
================================================
export interface Profile {
username: string;
bio: string;
image: string;
following: boolean;
}
================================================
FILE: src/models/register-input.model.ts
================================================
export interface RegisterInput {
email: string;
username: string;
password: string;
image?: string;
bio?: string;
}
================================================
FILE: src/models/registered-user.model.ts
================================================
export interface RegisteredUser {
email: string;
username: string;
bio: string | null;
image: string | null;
token: string;
}
================================================
FILE: src/models/tag.model.ts
================================================
export interface Tag {
name: string;
}
================================================
FILE: src/models/user.model.ts
================================================
import { Article } from './article.model';
import { Comment } from './comment.model';
export interface User {
id: number;
username: string;
email: string;
password: string;
bio: string | null;
image: any | null;
articles: Article[];
favorites: Article[];
followedBy: User[];
following: User[];
comments: Comment[];
}
================================================
FILE: src/routes/routes.ts
================================================
import { Router } from 'express';
import tagsController from '../controllers/tag.controller';
import articlesController from '../controllers/article.controller';
import authController from '../controllers/auth.controller';
import profileController from '../controllers/profile.controller';
const api = Router()
.use(tagsController)
.use(articlesController)
.use(profileController)
.use(authController);
export default Router().use('/api', api);
================================================
FILE: src/services/article.service.ts
================================================
import slugify from 'slugify';
import prisma from '../../prisma/prisma-client';
import HttpException from '../models/http-exception.model';
import { findUserIdByUsername } from './auth.service';
import profileMapper from '../utils/profile.utils';
const buildFindAllQuery = (query: any, username: string | undefined) => {
const queries: any = [];
const orAuthorQuery = [];
const andAuthorQuery = [];
if (username) {
orAuthorQuery.push({
username: {
equals: username,
},
});
}
if ('author' in query) {
andAuthorQuery.push({
username: {
equals: query.author,
},
});
}
const authorQuery = {
author: {
OR: orAuthorQuery,
AND: andAuthorQuery,
},
};
queries.push(authorQuery);
if ('tag' in query) {
queries.push({
tagList: {
some: {
name: query.tag,
},
},
});
}
if ('favorited' in query) {
queries.push({
favoritedBy: {
some: {
username: {
equals: query.favorited,
},
},
},
});
}
return queries;
};
export const getArticles = async (query: any, username?: string) => {
const andQueries = buildFindAllQuery(query, username);
const articlesCount = await prisma.article.count({
where: {
AND: andQueries,
},
});
const articles = await prisma.article.findMany({
where: { AND: andQueries },
orderBy: {
createdAt: 'desc',
},
skip: Number(query.offset) || 0,
take: Number(query.limit) || 10,
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return {
articles: articles.map(({ authorId, id, _count, favoritedBy, ...article }) => ({
...article,
author: profileMapper(article.author, username),
tagList: article.tagList.map(tag => tag.name),
favoritesCount: _count?.favoritedBy,
favorited: favoritedBy.some(item => item.username === username),
})),
articlesCount,
};
};
export const getFeed = async (offset: number, limit: number, username: string) => {
const user = await findUserIdByUsername(username);
const articlesCount = await prisma.article.count({
where: {
author: {
followedBy: { some: { id: user?.id } },
},
},
});
const articles = await prisma.article.findMany({
where: {
author: {
followedBy: { some: { id: user?.id } },
},
},
orderBy: {
createdAt: 'desc',
},
skip: offset || 0,
take: limit || 10,
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return {
articles: articles.map(({ authorId, id, _count, favoritedBy, ...article }) => ({
...article,
author: profileMapper(article.author, username),
tagList: article.tagList.map(tag => tag.name),
favoritesCount: _count?.favoritedBy,
favorited: favoritedBy.some(item => item.username === username),
})),
articlesCount,
};
};
export const createArticle = async (article: any, username: string) => {
const { title, description, body, tagList } = article;
if (!title) {
throw new HttpException(422, { errors: { title: ["can't be blank"] } });
}
if (!description) {
throw new HttpException(422, { errors: { description: ["can't be blank"] } });
}
if (!body) {
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
}
const user = await findUserIdByUsername(username);
const slug = `${slugify(title)}-${user?.id}`;
const existingTitle = await prisma.article.findUnique({
where: {
slug,
},
select: {
slug: true,
},
});
if (existingTitle) {
throw new HttpException(422, { errors: { title: ['must be unique'] } });
}
const { authorId, id, ...createdArticle } = await prisma.article.create({
data: {
title,
description,
body,
slug,
tagList: {
connectOrCreate: tagList.map((tag: string) => ({
create: { name: tag },
where: { name: tag },
})),
},
author: {
connect: {
id: user?.id,
},
},
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return {
...createdArticle,
tagList: createdArticle.tagList.map(tag => tag.name),
favoritesCount: createdArticle._count?.favoritedBy,
favorited: createdArticle.favoritedBy.some(item => item.username === username),
};
};
export const getArticle = async (slug: string, username?: string) => {
const article = await prisma.article.findUnique({
where: {
slug,
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return {
title: article?.title,
slug: article?.slug,
body: article?.body,
description: article?.description,
createdAt: article?.createdAt,
updatedAt: article?.updatedAt,
tagList: article?.tagList.map(tag => tag.name),
favoritesCount: article?._count?.favoritedBy,
favorited: article?.favoritedBy.some(item => item.username === username),
author: {
...article?.author,
following: article?.author.followedBy.some(follow => follow.username === username),
},
};
};
const disconnectArticlesTags = async (slug: string) => {
await prisma.article.update({
where: {
slug,
},
data: {
tagList: {
set: [],
},
},
});
};
export const updateArticle = async (article: any, slug: string, username: string) => {
let newSlug = null;
const user = await findUserIdByUsername(username);
if (article.title) {
newSlug = `${slugify(article.title)}-${user?.id}`;
if (newSlug !== slug) {
const existingTitle = await prisma.article.findFirst({
where: {
slug: newSlug,
},
select: {
slug: true,
},
});
if (existingTitle) {
throw new HttpException(422, { errors: { title: ['must be unique'] } });
}
}
}
const tagList = article.tagList?.length
? article.tagList.map((tag: string) => ({
create: { name: tag },
where: { name: tag },
}))
: [];
await disconnectArticlesTags(slug);
const updatedArticle = await prisma.article.update({
where: {
slug,
},
data: {
...(article.title ? { title: article.title } : {}),
...(article.body ? { body: article.body } : {}),
...(article.description ? { description: article.description } : {}),
...(newSlug ? { slug: newSlug } : {}),
updatedAt: new Date(),
tagList: {
connectOrCreate: tagList,
},
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return {
title: updatedArticle?.title,
slug: updatedArticle?.slug,
body: updatedArticle?.body,
description: updatedArticle?.description,
createdAt: updatedArticle?.createdAt,
updatedAt: updatedArticle?.updatedAt,
tagList: updatedArticle?.tagList.map(tag => tag.name),
favoritesCount: updatedArticle?._count?.favoritedBy,
favorited: updatedArticle?.favoritedBy.some(item => item.username === username),
author: updatedArticle?.author,
};
};
export const deleteArticle = async (slug: string) => {
await prisma.article.delete({
where: {
slug,
},
});
};
export const getCommentsByArticle = async (slug: string, username?: string) => {
const queries = [];
if (username) {
queries.push({
author: {
username,
},
});
}
const comments = await prisma.article.findUnique({
where: {
slug,
},
include: {
comments: {
where: {
OR: queries,
},
select: {
id: true,
createdAt: true,
updatedAt: true,
body: true,
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
},
},
},
});
const result = comments?.comments.map(comment => ({
...comment,
author: {
username: comment.author.username,
bio: comment.author.bio,
image: comment.author.image,
following: comment.author.followedBy.some(follow => follow.username === username),
},
}));
return result;
};
export const addComment = async (body: string, slug: string, username: string) => {
if (!body) {
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
}
const user = await findUserIdByUsername(username);
const article = await prisma.article.findUnique({
where: {
slug,
},
select: {
id: true,
},
});
const comment = await prisma.comment.create({
data: {
body,
article: {
connect: {
id: article?.id,
},
},
author: {
connect: {
id: user?.id,
},
},
},
include: {
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
},
});
return {
id: comment.id,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
body: comment.body,
author: {
username: comment.author.username,
bio: comment.author.bio,
image: comment.author.image,
following: comment.author.followedBy.some(follow => follow.id === user?.id),
},
};
};
export const deleteComment = async (id: number, username: string) => {
const comment = await prisma.comment.findFirst({
where: {
id,
author: {
username,
},
},
});
if (!comment) {
throw new HttpException(201, {});
}
await prisma.comment.delete({
where: {
id,
},
});
};
export const favoriteArticle = async (slugPayload: string, usernameAuth: string) => {
const user = await findUserIdByUsername(usernameAuth);
const { _count, ...article } = await prisma.article.update({
where: {
slug: slugPayload,
},
data: {
favoritedBy: {
connect: {
id: user?.id,
},
},
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
const result = {
...article,
author: profileMapper(article.author, usernameAuth),
tagList: article?.tagList.map(tag => tag.name),
favorited: article.favoritedBy.some(favorited => favorited.id === user?.id),
favoritesCount: _count?.favoritedBy,
};
return result;
};
export const unfavoriteArticle = async (slugPayload: string, usernameAuth: string) => {
const user = await findUserIdByUsername(usernameAuth);
const { _count, ...article } = await prisma.article.update({
where: {
slug: slugPayload,
},
data: {
favoritedBy: {
disconnect: {
id: user?.id,
},
},
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
const result = {
...article,
author: profileMapper(article.author, usernameAuth),
tagList: article?.tagList.map(tag => tag.name),
favorited: article.favoritedBy.some(favorited => favorited.id === user?.id),
favoritesCount: _count?.favoritedBy,
};
return result;
};
================================================
FILE: src/services/auth.service.ts
================================================
import bcrypt from 'bcryptjs';
import { RegisterInput } from '../models/register-input.model';
import prisma from '../../prisma/prisma-client';
import HttpException from '../models/http-exception.model';
import { RegisteredUser } from '../models/registered-user.model';
import generateToken from '../utils/token.utils';
import { User } from '../models/user.model';
const checkUserUniqueness = async (email: string, username: string) => {
const existingUserByEmail = await prisma.user.findUnique({
where: {
email,
},
select: {
id: true,
},
});
const existingUserByUsername = await prisma.user.findUnique({
where: {
username,
},
select: {
id: true,
},
});
if (existingUserByEmail || existingUserByUsername) {
throw new HttpException(422, {
errors: {
...(existingUserByEmail ? { email: ['has already been taken'] } : {}),
...(existingUserByUsername ? { username: ['has already been taken'] } : {}),
},
});
}
};
export const createUser = async (input: RegisterInput): Promise<RegisteredUser> => {
const email = input.email?.trim();
const username = input.username?.trim();
const password = input.password?.trim();
const { image, bio } = input;
if (!email) {
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
}
if (!username) {
throw new HttpException(422, { errors: { username: ["can't be blank"] } });
}
if (!password) {
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
}
await checkUserUniqueness(email, username);
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
email,
password: hashedPassword,
...(image ? { image } : {}),
...(bio ? { bio } : {}),
},
select: {
email: true,
username: true,
bio: true,
image: true,
},
});
return {
...user,
token: generateToken(user),
};
};
export const login = async (userPayload: any) => {
const email = userPayload.email?.trim();
const password = userPayload.password?.trim();
if (!email) {
throw new HttpException(422, { errors: { email: ["can't be blank"] } });
}
if (!password) {
throw new HttpException(422, { errors: { password: ["can't be blank"] } });
}
const user = await prisma.user.findUnique({
where: {
email,
},
select: {
email: true,
username: true,
password: true,
bio: true,
image: true,
},
});
if (user) {
const match = await bcrypt.compare(password, user.password);
if (match) {
return {
email: user.email,
username: user.username,
bio: user.bio,
image: user.image,
token: generateToken(user),
};
}
}
throw new HttpException(403, {
errors: {
'email or password': ['is invalid'],
},
});
};
export const getCurrentUser = async (username: string) => {
const user = (await prisma.user.findUnique({
where: {
username,
},
select: {
email: true,
username: true,
bio: true,
image: true,
},
})) as User;
return {
...user,
token: generateToken(user),
};
};
export const updateUser = async (userPayload: any, loggedInUsername: string) => {
const { email, username, password, image, bio } = userPayload;
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.update({
where: {
username: loggedInUsername,
},
data: {
...(email ? { email } : {}),
...(username ? { username } : {}),
...(password ? { password: hashedPassword } : {}),
...(image ? { image } : {}),
...(bio ? { bio } : {}),
},
select: {
email: true,
username: true,
bio: true,
image: true,
},
});
return {
...user,
token: generateToken(user),
};
};
export const findUserIdByUsername = async (username: string) => {
const user = await prisma.user.findUnique({
where: {
username,
},
select: {
id: true,
},
});
if (!user) {
throw new HttpException(404, {});
}
return user;
};
================================================
FILE: src/services/profile.service.ts
================================================
import prisma from '../../prisma/prisma-client';
import profileMapper from '../utils/profile.utils';
import HttpException from '../models/http-exception.model';
import { findUserIdByUsername } from './auth.service';
export const getProfile = async (usernamePayload: string, usernameAuth: string) => {
const profile = await prisma.user.findUnique({
where: {
username: usernamePayload,
},
include: {
followedBy: true,
},
});
if (!profile) {
throw new HttpException(404, {});
}
return profileMapper(profile, usernameAuth);
};
export const followUser = async (usernamePayload: string, usernameAuth: string) => {
const { id } = await findUserIdByUsername(usernameAuth);
const profile = await prisma.user.update({
where: {
username: usernamePayload,
},
data: {
followedBy: {
connect: {
id,
},
},
},
include: {
followedBy: true,
},
});
return profileMapper(profile, usernameAuth);
};
export const unfollowUser = async (usernamePayload: string, usernameAuth: string) => {
const { id } = await findUserIdByUsername(usernameAuth);
const profile = await prisma.user.update({
where: {
username: usernamePayload,
},
data: {
followedBy: {
disconnect: {
id,
},
},
},
include: {
followedBy: true,
},
});
return profileMapper(profile, usernameAuth);
};
================================================
FILE: src/services/tag.service.ts
================================================
import prisma from '../../prisma/prisma-client';
const getTags = async (username?: string): Promise<string[]> => {
const queries = [];
if (username) {
queries.push({
username: {
equals: username,
},
});
}
const tags = await prisma.tag.groupBy({
where: {
articles: {
some: {
author: {
OR: queries,
},
},
},
},
by: ['name'],
orderBy: {
_count: {
name: 'desc',
},
},
take: 10,
});
return tags.map(tag => tag.name);
};
export default getTags;
================================================
FILE: src/utils/auth.ts
================================================
const jwt = require('express-jwt');
const getTokenFromHeaders = (req: { headers: { authorization: string } }): string | null => {
if (
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Token') ||
(req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer')
) {
return req.headers.authorization.split(' ')[1];
}
return null;
};
const auth = {
required: jwt({
secret: process.env.JWT_SECRET || 'superSecret',
getToken: getTokenFromHeaders,
algorithms: ['HS256'],
}),
optional: jwt({
secret: process.env.JWT_SECRET || 'superSecret',
credentialsRequired: false,
getToken: getTokenFromHeaders,
algorithms: ['HS256'],
}),
};
export default auth;
================================================
FILE: src/utils/profile.utils.ts
================================================
import { User } from '../models/user.model';
import { Profile } from '../models/profile.model';
const profileMapper = (user: any, username: string | undefined): Profile => ({
username: user.username,
bio: user.bio,
image: user.image,
following: username
? user?.followedBy.some((followingUser: Partial<User>) => followingUser.username === username)
: false,
});
export default profileMapper;
================================================
FILE: src/utils/token.utils.ts
================================================
import jwt from 'jsonwebtoken';
import { User } from '../models/user.model';
const generateToken = (user: Partial<User>): string =>
jwt.sign(user, process.env.JWT_SECRET || 'superSecret', { expiresIn: '60d' });
export default generateToken;
================================================
FILE: src/utils/user-request.d.ts
================================================
declare namespace Express {
export interface Request {
user?: {
username?: string;
};
}
}
================================================
FILE: tests/prisma-mock.ts
================================================
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
import prisma from '../prisma/prisma-client';
jest.mock('../prisma/prisma-client', () => ({
__esModule: true,
default: mockDeep<PrismaClient>(),
}));
const prismaMock = prisma as unknown as DeepMockProxy<PrismaClient>;
beforeEach(() => {
mockReset(prismaMock);
});
export default prismaMock;
================================================
FILE: tests/services/article.service.test.ts
================================================
import prismaMock from '../prisma-mock';
import {
deleteComment,
favoriteArticle,
unfavoriteArticle,
} from '../../src/services/article.service';
describe('ArticleService', () => {
describe('deleteComment', () => {
test('should throw an error ', () => {
// Given
const id = 123;
const username = 'RealWorld';
// When
prismaMock.comment.findFirst.mockResolvedValue(null);
// Then
expect(deleteComment(id, username)).rejects.toThrowError();
});
});
describe('favoriteArticle', () => {
test('should return the favorited article', async () => {
// Given
const slug = 'How-to-train-your-dragon';
const username = 'RealWorld';
const mockedUserResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
const mockedArticleResponse = {
id: 123,
slug: 'How-to-train-your-dragon',
title: 'How to train your dragon',
description: '',
body: '',
createdAt: new Date(),
updatedAt: new Date(),
authorId: 456,
tagList: [],
favoritedBy: [],
author: {
username: 'RealWorld',
bio: null,
image: null,
followedBy: [],
},
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
// Then
await expect(favoriteArticle(slug, username)).resolves.toHaveProperty('favoritesCount');
});
test('should throw an error if no user is found', async () => {
// Given
const slug = 'how-to-train-your-dragon';
const username = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(favoriteArticle(slug, username)).rejects.toThrowError();
});
});
describe('unfavoriteArticle', () => {
test('should return the unfavorited article', async () => {
// Given
const slug = 'How-to-train-your-dragon';
const username = 'RealWorld';
const mockedUserResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
const mockedArticleResponse = {
id: 123,
slug: 'How-to-train-your-dragon',
title: 'How to train your dragon',
description: '',
body: '',
createdAt: new Date(),
updatedAt: new Date(),
authorId: 456,
tagList: [],
favoritedBy: [],
author: {
username: 'RealWorld',
bio: null,
image: null,
followedBy: [],
},
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
// Then
await expect(unfavoriteArticle(slug, username)).resolves.toHaveProperty('favoritesCount');
});
test('should throw an error if no user is found', async () => {
// Given
const slug = 'how-to-train-your-dragon';
const username = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(unfavoriteArticle(slug, username)).rejects.toThrowError();
});
});
});
================================================
FILE: tests/services/auth.service.test.ts
================================================
import bcrypt from 'bcryptjs';
import { createUser, getCurrentUser, login, updateUser } from '../../src/services/auth.service';
import prismaMock from '../prisma-mock';
describe('AuthService', () => {
describe('createUser', () => {
test('should create new user ', async () => {
// Given
const user = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
};
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.create.mockResolvedValue(mockedResponse);
// Then
await expect(createUser(user)).resolves.toHaveProperty('token');
});
test('should throw an error when creating new user with empty username ', async () => {
// Given
const user = {
id: 123,
username: ' ',
email: 'realworld@me',
password: '1234',
};
// Then
const error = String({ errors: { username: ["can't be blank"] } });
await expect(createUser(user)).rejects.toThrow(error);
});
test('should throw an error when creating new user with empty email ', async () => {
// Given
const user = {
id: 123,
username: 'RealWorld',
email: ' ',
password: '1234',
};
// Then
const error = String({ errors: { email: ["can't be blank"] } });
await expect(createUser(user)).rejects.toThrow(error);
});
test('should throw an error when creating new user with empty password ', async () => {
// Given
const user = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: ' ',
};
// Then
const error = String({ errors: { password: ["can't be blank"] } });
await expect(createUser(user)).rejects.toThrow(error);
});
test('should throw an exception when creating a new user with already existing user on same username ', async () => {
// Given
const user = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
};
const mockedExistingUser = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedExistingUser);
// Then
const error = { email: ['has already been taken'] }.toString();
await expect(createUser(user)).rejects.toThrow(error);
});
});
describe('login', () => {
test('should return a token', async () => {
// Given
const user = {
email: 'realworld@me',
password: '1234',
};
const hashedPassword = await bcrypt.hash(user.password, 10);
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: hashedPassword,
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
await expect(login(user)).resolves.toHaveProperty('token');
});
test('should throw an error when the email is empty', async () => {
// Given
const user = {
email: ' ',
password: '1234',
};
// Then
const error = String({ errors: { email: ["can't be blank"] } });
await expect(login(user)).rejects.toThrow(error);
});
test('should throw an error when the password is empty', async () => {
// Given
const user = {
email: 'realworld@me',
password: ' ',
};
// Then
const error = String({ errors: { password: ["can't be blank"] } });
await expect(login(user)).rejects.toThrow(error);
});
test('should throw an error when no user is found', async () => {
// Given
const user = {
email: 'realworld@me',
password: '1234',
};
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
const error = String({ errors: { 'email or password': ['is invalid'] } });
await expect(login(user)).rejects.toThrow(error);
});
test('should throw an error if the password is wrong', async () => {
// Given
const user = {
email: 'realworld@me',
password: '1234',
};
const hashedPassword = await bcrypt.hash('4321', 10);
const mockedResponse = {
id: 123,
username: 'Gerome',
email: 'realworld@me',
password: hashedPassword,
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
const error = String({ errors: { 'email or password': ['is invalid'] } });
await expect(login(user)).rejects.toThrow(error);
});
});
describe('getCurrentUser', () => {
test('should return a token', async () => {
// Given
const username = 'RealWorld';
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
await expect(getCurrentUser(username)).resolves.toHaveProperty('token');
});
});
describe('updateUser', () => {
test('should return a token', async () => {
// Given
const user = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
};
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
};
// When
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(updateUser(user, user.username)).resolves.toHaveProperty('token');
});
});
});
================================================
FILE: tests/services/profile.service.test.ts
================================================
import prismaMock from '../prisma-mock';
import { followUser, getProfile, unfollowUser } from '../../src/services/profile.service';
describe('ProfileService', () => {
describe('getProfile', () => {
test('should return a following property', async () => {
// Given
const username = 'RealWorld';
const usernameAuth = 'Gerome';
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
followedBy: [],
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
await expect(getProfile(username, usernameAuth)).resolves.toHaveProperty('following');
});
test('should throw an error if no user is found', async () => {
// Given
const username = 'RealWorld';
const usernameAuth = 'Gerome';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(getProfile(username, usernameAuth)).rejects.toThrowError();
});
});
describe('followUser', () => {
test('shoud return a following property', async () => {
// Given
const usernamePayload = 'AnotherUser';
const usernameAuth = 'RealWorld';
const mockedAuthUser = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
followedBy: [],
};
const mockedResponse = {
id: 123,
username: 'AnotherUser',
email: 'another@me',
password: '1234',
bio: null,
image: null,
token: '',
followedBy: [],
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(followUser(usernamePayload, usernameAuth)).resolves.toHaveProperty('following');
});
test('shoud throw an error if no user is found', async () => {
// Given
const usernamePayload = 'AnotherUser';
const usernameAuth = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(followUser(usernamePayload, usernameAuth)).rejects.toThrowError();
});
});
describe('unfollowUser', () => {
test('shoud return a following property', async () => {
// Given
const usernamePayload = 'AnotherUser';
const usernameAuth = 'RealWorld';
const mockedAuthUser = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
followedBy: [],
};
const mockedResponse = {
id: 123,
username: 'AnotherUser',
email: 'another@me',
password: '1234',
bio: null,
image: null,
token: '',
followedBy: [],
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(unfollowUser(usernamePayload, usernameAuth)).resolves.toHaveProperty(
'following',
);
});
test('shoud throw an error if no user is found', async () => {
// Given
const usernamePayload = 'AnotherUser';
const usernameAuth = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(unfollowUser(usernamePayload, usernameAuth)).rejects.toThrowError();
});
});
});
================================================
FILE: tests/services/tag.service.test.ts
================================================
describe('TagService', () => {
describe('getTags', () => {
// TODO : prismaMock.tag.groupBy.mockResolvedValue(mockedResponse) doesn't work
test.todo('should return a list of strings');
});
});
================================================
FILE: tests/utils/profile.utils.test.ts
================================================
import profileMapper from '../../src/utils/profile.utils';
describe('ProfileUtils', () => {
describe('profileMapper', () => {
test('should return a profile', () => {
// Given
const user = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
followedBy: [],
};
const username = 'RealWorld';
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: false,
};
// Then
expect(profileMapper(user, username)).toEqual(expected);
});
test('should return a profile followed by the user', () => {
// Given
const user = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
followedBy: [
{
username: 'RealWorld',
},
],
};
const username = 'RealWorld';
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: true,
};
// Then
expect(profileMapper(user, username)).toEqual(expected);
});
test('should return a profile not followed by the user', () => {
// Given
const user = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
followedBy: [
{
username: 'NotRealWorld',
},
],
};
const username = 'RealWorld';
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: false,
};
// Then
expect(profileMapper(user, username)).toEqual(expected);
});
});
});
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"lib": ["es2015"] /* Specify library files to be included in the compilation. */,
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "dist" /* Redirect output structure to the directory. */,
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"resolveJsonModule": true,
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"exclude": ["tests"]
}
gitextract_bhrnvjgz/ ├── .eslintrc.json ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── ci.yaml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Procfile ├── README.md ├── app.json ├── docs/ │ └── swagger.json ├── jest.config.js ├── package.json ├── prisma/ │ ├── migrations/ │ │ ├── 20210924222830_initial/ │ │ │ └── migration.sql │ │ ├── 20211001195651_implicit_articles/ │ │ │ └── migration.sql │ │ ├── 20211105082430_api_url/ │ │ │ └── migration.sql │ │ └── migration_lock.toml │ ├── prisma-client.ts │ └── schema.prisma ├── src/ │ ├── controllers/ │ │ ├── article.controller.ts │ │ ├── auth.controller.ts │ │ ├── profile.controller.ts │ │ └── tag.controller.ts │ ├── index.ts │ ├── models/ │ │ ├── article.model.ts │ │ ├── comment.model.ts │ │ ├── http-exception.model.ts │ │ ├── profile.model.ts │ │ ├── register-input.model.ts │ │ ├── registered-user.model.ts │ │ ├── tag.model.ts │ │ └── user.model.ts │ ├── routes/ │ │ └── routes.ts │ ├── services/ │ │ ├── article.service.ts │ │ ├── auth.service.ts │ │ ├── profile.service.ts │ │ └── tag.service.ts │ └── utils/ │ ├── auth.ts │ ├── profile.utils.ts │ ├── token.utils.ts │ └── user-request.d.ts ├── tests/ │ ├── prisma-mock.ts │ ├── services/ │ │ ├── article.service.test.ts │ │ ├── auth.service.test.ts │ │ ├── profile.service.test.ts │ │ └── tag.service.test.ts │ └── utils/ │ └── profile.utils.test.ts └── tsconfig.json
SYMBOL INDEX (30 symbols across 13 files)
FILE: prisma/migrations/20210924222830_initial/migration.sql
type "Article" (line 2) | CREATE TABLE "Article" (
type "ArticleTags" (line 16) | CREATE TABLE "ArticleTags" (
type "Comment" (line 24) | CREATE TABLE "Comment" (
type "Tag" (line 36) | CREATE TABLE "Tag" (
type "User" (line 44) | CREATE TABLE "User" (
type "_UserFavorites" (line 56) | CREATE TABLE "_UserFavorites" (
type "_UserFollows" (line 62) | CREATE TABLE "_UserFollows" (
type "Article" (line 68) | CREATE UNIQUE INDEX "Article.slug_unique" ON "Article"("slug")
type "User" (line 71) | CREATE UNIQUE INDEX "User.email_unique" ON "User"("email")
type "User" (line 74) | CREATE UNIQUE INDEX "User.username_unique" ON "User"("username")
type "_UserFavorites" (line 77) | CREATE UNIQUE INDEX "_UserFavorites_AB_unique" ON "_UserFavorites"("A", ...
type "_UserFavorites" (line 80) | CREATE INDEX "_UserFavorites_B_index" ON "_UserFavorites"("B")
type "_UserFollows" (line 83) | CREATE UNIQUE INDEX "_UserFollows_AB_unique" ON "_UserFollows"("A", "B")
type "_UserFollows" (line 86) | CREATE INDEX "_UserFollows_B_index" ON "_UserFollows"("B")
FILE: prisma/migrations/20211001195651_implicit_articles/migration.sql
type "_ArticleToTag" (line 18) | CREATE TABLE "_ArticleToTag" (
type "_ArticleToTag" (line 24) | CREATE UNIQUE INDEX "_ArticleToTag_AB_unique" ON "_ArticleToTag"("A", "B")
type "_ArticleToTag" (line 27) | CREATE INDEX "_ArticleToTag_B_index" ON "_ArticleToTag"("B")
type "Tag" (line 30) | CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name")
FILE: prisma/prisma-client.ts
type CustomNodeJsGlobal (line 5) | interface CustomNodeJsGlobal extends NodeJS.Global {
FILE: src/index.ts
constant PORT (line 57) | const PORT = process.env.PORT || 3000;
FILE: src/models/article.model.ts
type Article (line 3) | interface Article {
FILE: src/models/comment.model.ts
type Comment (line 3) | interface Comment {
FILE: src/models/http-exception.model.ts
class HttpException (line 1) | class HttpException extends Error {
method constructor (line 4) | constructor(errorCode: number, public readonly message: string | any) {
FILE: src/models/profile.model.ts
type Profile (line 1) | interface Profile {
FILE: src/models/register-input.model.ts
type RegisterInput (line 1) | interface RegisterInput {
FILE: src/models/registered-user.model.ts
type RegisteredUser (line 1) | interface RegisteredUser {
FILE: src/models/tag.model.ts
type Tag (line 1) | interface Tag {
FILE: src/models/user.model.ts
type User (line 4) | interface User {
FILE: src/utils/user-request.d.ts
type Request (line 2) | interface Request {
Condensed preview — 52 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (121K chars).
[
{
"path": ".eslintrc.json",
"chars": 845,
"preview": "{\n \"env\": {\n \"browser\": true,\n \"es2021\": true,\n \"jest\": true\n },\n \"extends\": [\"airbnb-base\", \"prettier\"],\n "
},
{
"path": ".github/dependabot.yml",
"chars": 264,
"preview": "version: 2\nupdates:\n - package-ecosystem: github-actions\n directory: '/'\n schedule:\n interval: weekly\n op"
},
{
"path": ".github/workflows/ci.yaml",
"chars": 1072,
"preview": "name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches:\n - '**'\n\njobs:\n run_tests:\n runs-on: u"
},
{
"path": ".gitignore",
"chars": 296,
"preview": "/node_modules\n/dist\n\n# Keep environment variables out of version control\n.env\n\n# IDEs and editors\n/.idea\n.project\n.class"
},
{
"path": ".husky/.gitignore",
"chars": 2,
"preview": "_\n"
},
{
"path": ".husky/pre-commit",
"chars": 58,
"preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
},
{
"path": ".prettierignore",
"chars": 6,
"preview": "/dist\n"
},
{
"path": ".prettierrc.json",
"chars": 125,
"preview": "{\n \"printWidth\": 100,\n \"singleQuote\": true,\n \"trailingComma\": \"all\",\n \"bracketSpacing\": true,\n \"arrowParens\": \"avoi"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 5219,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
},
{
"path": "CONTRIBUTING.md",
"chars": 6419,
"preview": "# Contributing to RealWorld\n\nWe would love for you to contribute to RealWorld and help make it even better than it is\nto"
},
{
"path": "LICENSE",
"chars": 1056,
"preview": "MIT License\n\nCopyright (c) 2021\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this so"
},
{
"path": "Procfile",
"chars": 51,
"preview": "web: npm start\n\nrelease: npx prisma migrate deploy\n"
},
{
"path": "README.md",
"chars": 1694,
"preview": "# \n\n> Official NodeJS codebase that adheres to the [RealWorld](https://gothinks"
},
{
"path": "app.json",
"chars": 451,
"preview": "{\n \"name\": \"RealWorld API\",\n \"description\": \"Node / Express / Prisma API for RealWorld project\",\n \"keywords\": [\"node\""
},
{
"path": "docs/swagger.json",
"chars": 27938,
"preview": "{\n \"swagger\": \"2.0\",\n \"info\": {\n \"description\": \"Conduit API\",\n \"version\": \"1.0.0\",\n \"title\": \"Conduit API\",\n"
},
{
"path": "jest.config.js",
"chars": 148,
"preview": "module.exports = {\n clearMocks: true,\n preset: 'ts-jest',\n testEnvironment: 'node',\n setupFilesAfterEnv: ['<rootDir>"
},
{
"path": "package.json",
"chars": 2351,
"preview": "{\n \"name\": \"express-prisma-realworld-official-app\",\n \"version\": \"1.0.0\",\n \"description\": \"Node.js, Express.js & Prism"
},
{
"path": "prisma/migrations/20210924222830_initial/migration.sql",
"chars": 3202,
"preview": "-- CreateTable\nCREATE TABLE \"Article\" (\n \"id\" SERIAL NOT NULL,\n \"slug\" TEXT NOT NULL,\n \"title\" TEXT NOT NULL,\n "
},
{
"path": "prisma/migrations/20211001195651_implicit_articles/migration.sql",
"chars": 1101,
"preview": "/*\n Warnings:\n\n - You are about to drop the `ArticleTags` table. If the table is not empty, all the data it contains w"
},
{
"path": "prisma/migrations/20211105082430_api_url/migration.sql",
"chars": 120,
"preview": "-- AlterTable\nALTER TABLE \"User\" ALTER COLUMN \"image\" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg';\n"
},
{
"path": "prisma/migrations/migration_lock.toml",
"chars": 126,
"preview": "# Please do not edit this file manually\n# It should be added in your version-control system (i.e. Git)\nprovider = \"postg"
},
{
"path": "prisma/prisma-client.ts",
"chars": 500,
"preview": "import { PrismaClient } from '@prisma/client';\n\n// add prisma to the NodeJS global type\n// TODO : downgraded @types/node"
},
{
"path": "prisma/schema.prisma",
"chars": 1837,
"preview": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ndatasource db {"
},
{
"path": "src/controllers/article.controller.ts",
"chars": 5870,
"preview": "import { NextFunction, Request, Response, Router } from 'express';\nimport auth from '../utils/auth';\nimport {\n addComme"
},
{
"path": "src/controllers/auth.controller.ts",
"chars": 1577,
"preview": "import { NextFunction, Request, Response, Router } from 'express';\nimport auth from '../utils/auth';\nimport { createUser"
},
{
"path": "src/controllers/profile.controller.ts",
"chars": 1556,
"preview": "import { NextFunction, Request, Response, Router } from 'express';\nimport auth from '../utils/auth';\nimport { followUser"
},
{
"path": "src/controllers/tag.controller.ts",
"chars": 538,
"preview": "import { NextFunction, Request, Response, Router } from 'express';\nimport auth from '../utils/auth';\nimport getTags from"
},
{
"path": "src/index.ts",
"chars": 1541,
"preview": "import express, { NextFunction, Request, Response } from 'express';\nimport cors from 'cors';\nimport swaggerUi from 'swag"
},
{
"path": "src/models/article.model.ts",
"chars": 188,
"preview": "import { Comment } from './comment.model';\n\nexport interface Article {\n id: number;\n title: string;\n slug: string;\n "
},
{
"path": "src/models/comment.model.ts",
"chars": 162,
"preview": "import { Article } from './article.model';\n\nexport interface Comment {\n id: number;\n createdAt: Date;\n updatedAt: Dat"
},
{
"path": "src/models/http-exception.model.ts",
"chars": 221,
"preview": "class HttpException extends Error {\n errorCode: number;\n\n constructor(errorCode: number, public readonly message: stri"
},
{
"path": "src/models/profile.model.ts",
"chars": 103,
"preview": "export interface Profile {\n username: string;\n bio: string;\n image: string;\n following: boolean;\n}\n"
},
{
"path": "src/models/register-input.model.ts",
"chars": 126,
"preview": "export interface RegisterInput {\n email: string;\n username: string;\n password: string;\n image?: string;\n bio?: stri"
},
{
"path": "src/models/registered-user.model.ts",
"chars": 136,
"preview": "export interface RegisteredUser {\n email: string;\n username: string;\n bio: string | null;\n image: string | null;\n t"
},
{
"path": "src/models/tag.model.ts",
"chars": 41,
"preview": "export interface Tag {\n name: string;\n}\n"
},
{
"path": "src/models/user.model.ts",
"chars": 340,
"preview": "import { Article } from './article.model';\nimport { Comment } from './comment.model';\n\nexport interface User {\n id: num"
},
{
"path": "src/routes/routes.ts",
"chars": 455,
"preview": "import { Router } from 'express';\nimport tagsController from '../controllers/tag.controller';\nimport articlesController "
},
{
"path": "src/services/article.service.ts",
"chars": 13218,
"preview": "import slugify from 'slugify';\nimport prisma from '../../prisma/prisma-client';\nimport HttpException from '../models/htt"
},
{
"path": "src/services/auth.service.ts",
"chars": 4245,
"preview": "import bcrypt from 'bcryptjs';\nimport { RegisterInput } from '../models/register-input.model';\nimport prisma from '../.."
},
{
"path": "src/services/profile.service.ts",
"chars": 1451,
"preview": "import prisma from '../../prisma/prisma-client';\nimport profileMapper from '../utils/profile.utils';\nimport HttpExceptio"
},
{
"path": "src/services/tag.service.ts",
"chars": 588,
"preview": "import prisma from '../../prisma/prisma-client';\n\nconst getTags = async (username?: string): Promise<string[]> => {\n co"
},
{
"path": "src/utils/auth.ts",
"chars": 747,
"preview": "const jwt = require('express-jwt');\n\nconst getTokenFromHeaders = (req: { headers: { authorization: string } }): string |"
},
{
"path": "src/utils/profile.utils.ts",
"chars": 410,
"preview": "import { User } from '../models/user.model';\nimport { Profile } from '../models/profile.model';\n\nconst profileMapper = ("
},
{
"path": "src/utils/token.utils.ts",
"chars": 245,
"preview": "import jwt from 'jsonwebtoken';\nimport { User } from '../models/user.model';\n\nconst generateToken = (user: Partial<User>"
},
{
"path": "src/utils/user-request.d.ts",
"chars": 108,
"preview": "declare namespace Express {\n export interface Request {\n user?: {\n username?: string;\n };\n }\n}\n"
},
{
"path": "tests/prisma-mock.ts",
"chars": 423,
"preview": "import { PrismaClient } from '@prisma/client';\nimport { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';\n"
},
{
"path": "tests/services/article.service.test.ts",
"chars": 3497,
"preview": "import prismaMock from '../prisma-mock';\nimport {\n deleteComment,\n favoriteArticle,\n unfavoriteArticle,\n} from '../.."
},
{
"path": "tests/services/auth.service.test.ts",
"chars": 6262,
"preview": "import bcrypt from 'bcryptjs';\nimport { createUser, getCurrentUser, login, updateUser } from '../../src/services/auth.se"
},
{
"path": "tests/services/profile.service.test.ts",
"chars": 3699,
"preview": "import prismaMock from '../prisma-mock';\nimport { followUser, getProfile, unfollowUser } from '../../src/services/profil"
},
{
"path": "tests/services/tag.service.test.ts",
"chars": 205,
"preview": "describe('TagService', () => {\n describe('getTags', () => {\n // TODO : prismaMock.tag.groupBy.mockResolvedValue(mock"
},
{
"path": "tests/utils/profile.utils.test.ts",
"chars": 1749,
"preview": "import profileMapper from '../../src/utils/profile.utils';\n\ndescribe('ProfileUtils', () => {\n describe('profileMapper',"
},
{
"path": "tsconfig.json",
"chars": 5970,
"preview": "{\n \"compilerOptions\": {\n /* Visit https://aka.ms/tsconfig.json to read more about this file */\n\n /* Basic Options"
}
]
About this extraction
This page contains the full source code of the gothinkster/node-express-prisma-v1-official-app GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 52 files (108.0 KB), approximately 27.5k tokens, and a symbol index with 30 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.