chore: ingest source code

58 files from https://github.com/gothinkster/node-express-realworld-example-app
This commit is contained in:
2026-03-13 16:01:40 +00:00
commit cae1f5b259
58 changed files with 12898 additions and 0 deletions

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"dbaeumer.vscode-eslint"
]
}

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Build stage - compile TypeScript with Nx esbuild
FROM node:20-bullseye-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY nx.json project.json tsconfig*.json ./
COPY src ./src
RUN DATABASE_URL=postgresql://x:x@localhost/placeholder npx prisma generate --schema=src/prisma/schema.prisma
RUN npx nx build api --configuration=production
# Runtime stage — Debian bullseye-slim ships OpenSSL 1.1 (required by Prisma 4.x)
FROM node:20-bullseye-slim
WORKDIR /app
RUN groupadd --system api && \
useradd --system -g api api
COPY --from=build /app/dist/api/ ./
# Copy Prisma generated native client (Debian OpenSSL 1.1 binary)
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
# Copy @prisma/client package
COPY --from=build /app/node_modules/@prisma ./node_modules/@prisma
# Copy Prisma CLI so migrate/push can run
COPY --from=build /app/node_modules/prisma ./node_modules/prisma
COPY --from=build /app/node_modules/.bin/prisma ./node_modules/.bin/prisma
# Copy schema so Prisma can run migrations at startup
COPY --from=build /app/src/prisma ./src/prisma
RUN npm install --omit=dev --ignore-scripts 2>/dev/null || npm install --omit=dev
RUN chown -R api:api .
USER api
EXPOSE 3000
CMD ["sh", "-c", "node_modules/.bin/prisma migrate deploy --schema=src/prisma/schema.prisma 2>/dev/null || true && exec node main.js"]

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# ![Node/Express/Prisma Example App](project-logo.png)
[![Build Status](https://travis-ci.org/anishkny/node-express-realworld-example-app.svg?branch=master)](https://travis-ci.org/anishkny/node-express-realworld-example-app)
> ### Example Node (Express + Prisma) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld-example-apps) API spec.
<a href="https://thinkster.io/tutorials/node-json-api" target="_blank"><img width="454" src="https://raw.githubusercontent.com/gothinkster/realworld/master/media/learn-btn-hr.png" /></a>
## Getting Started
### Prerequisites
Run the following command to install dependencies:
```shell
npm install
```
### Environment variables
This project depends on some environment variables.
If you are running this project locally, create a `.env` file at the root for these variables.
Your host provider should included a feature to set them there directly to avoid exposing them.
Here are the required ones:
```
DATABASE_URL=
JWT_SECRET=
NODE_ENV=production
```
### Generate your Prisma client
Run the following command to generate the Prisma Client which will include types based on your database schema:
```shell
npx prisma generate
```
### Apply any SQL migration script
Run the following command to create/update your database based on existing sql migration scripts:
```shell
npx prisma migrate deploy
```
### Run the project
Run the following command to run the project:
```shell
npx nx serve api
```
### Seed the database
The project includes a seed script to populate the database:
```shell
npx prisma db seed
```
## Deploy on a remote server
Run the following command to:
- install dependencies
- apply any new migration sql scripts
- run the server
```shell
npm ci && npx prisma migrate deploy && node dist/api/main.js
```

19
e2e/jest.config.ts Normal file
View File

@@ -0,0 +1,19 @@
/* eslint-disable */
export default {
displayName: 'e2e',
preset: '../jest.preset.js',
globalSetup: '<rootDir>/src/support/global-setup.ts',
globalTeardown: '<rootDir>/src/support/global-teardown.ts',
setupFiles: ['<rootDir>/src/support/test-setup.ts'],
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
},
],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../coverage/e2e',
};

20
e2e/project.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "e2e",
"$schema": "../node_modules/nx/schemas/project-schema.json",
"implicitDependencies": ["api"],
"projectType": "application",
"targets": {
"e2e": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"],
"options": {
"jestConfig": "e2e/jest.config.ts",
"passWithNoTests": true
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
}
}
}

View File

@@ -0,0 +1,10 @@
import axios from 'axios';
describe('GET /', () => {
it('should return a message', async () => {
const res = await axios.get(`/`);
expect(res.status).toBe(200);
expect(res.data).toEqual({ message: 'Hello API' });
});
});

View File

@@ -0,0 +1,10 @@
/* eslint-disable */
var __TEARDOWN_MESSAGE__: string;
module.exports = async function () {
// Start services that that the app needs to run (e.g. database, docker-compose, etc.).
console.log('\nSetting up...\n');
// Hint: Use `globalThis` to pass variables to global teardown.
globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n';
};

View File

@@ -0,0 +1,7 @@
/* eslint-disable */
module.exports = async function () {
// Put clean up logic here (e.g. stopping services, docker-compose, etc.).
// Hint: `globalThis` is shared between setup and teardown.
console.log(globalThis.__TEARDOWN_MESSAGE__);
};

View File

@@ -0,0 +1,10 @@
/* eslint-disable */
import axios from 'axios';
module.exports = async function () {
// Configure axios for tests to use.
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ?? '3000';
axios.defaults.baseURL = `http://${host}:${port}`;
};

13
e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../tsconfig.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"esModuleInterop": true
}
}

10
e2e/tsconfig.spec.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts", "src/**/*.ts"]
}

15
jest.config.ts Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-disable */
export default {
displayName: 'api',
preset: './jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: './coverage/api',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
'<rootDir>/src/**/*(*.)@(spec|test).[jt]s?(x)',
],
};

3
jest.preset.js Normal file
View File

@@ -0,0 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };

46
nx.json Normal file
View File

@@ -0,0 +1,46 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"lint": {
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.js"
]
},
"@nx/jest:jest": {
"cache": true,
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/test-setup.[jt]s",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.js"
],
"sharedGlobals": []
}
}

9898
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

58
package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "@api/source",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"start": "nx serve",
"build": "nx build",
"test": "nx test"
},
"private": true,
"prisma": {
"seed": "ts-node --transpile-only --compiler-options {\"module\":\"CommonJS\"} src/prisma/seed.ts",
"schema": "src/prisma/schema.prisma"
},
"dependencies": {
"@ngneat/falso": "^7.1.1",
"@prisma/client": "^4.16.1",
"axios": "^1.0.0",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "~4.18.1",
"express-jwt": "^8.4.1",
"jsonwebtoken": "^9.0.2",
"slugify": "^1.6.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@nx/esbuild": "17.2.6",
"@nx/eslint": "17.2.6",
"@nx/eslint-plugin": "17.2.6",
"@nx/jest": "17.2.6",
"@nx/js": "17.2.6",
"@nx/node": "17.2.6",
"@nx/workspace": "17.2.6",
"@swc-node/register": "~1.6.7",
"@swc/core": "~1.3.85",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "~4.17.13",
"@types/jest": "^29.4.0",
"@types/node": "18.16.9",
"@typescript-eslint/eslint-plugin": "^6.9.1",
"@typescript-eslint/parser": "^6.9.1",
"esbuild": "^0.19.2",
"eslint": "~8.48.0",
"eslint-config-prettier": "^9.0.0",
"jest": "^29.4.1",
"jest-environment-node": "^29.4.1",
"jest-mock-extended": "^3.0.5",
"nx": "17.2.6",
"prettier": "^2.6.2",
"prisma": "^4.16.1",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"typescript": "~5.2.2"
}
}

75
project.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "api",
"$schema": "node_modules/nx/schemas/project-schema.json",
"sourceRoot": "src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"platform": "node",
"outputPath": "dist/api",
"format": ["cjs"],
"bundle": false,
"main": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": ["src/assets/**/**"],
"generatePackageJson": true,
"esbuildOptions": {
"sourcemap": true,
"outExtension": {
".js": ".js"
}
}
},
"configurations": {
"development": {},
"production": {
"generateLockfile": true,
"esbuildOptions": {
"sourcemap": false,
"outExtension": {
".js": ".js"
}
}
}
}
},
"serve": {
"executor": "@nx/js:node",
"defaultConfiguration": "development",
"options": {
"buildTarget": "api:build"
},
"configurations": {
"development": {
"buildTarget": "api:build:development"
},
"production": {
"buildTarget": "api:build:production"
}
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["./src"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectName}"],
"options": {
"jestConfig": "jest.config.ts"
}
},
"docker-build": {
"dependsOn": ["build"],
"command": "docker build -f Dockerfile . -t api"
}
},
"tags": []
}

View File

@@ -0,0 +1,12 @@
class HttpException extends Error {
errorCode: number;
constructor(
errorCode: number,
public readonly message: string | any,
) {
super(message);
this.errorCode = errorCode;
}
}
export default HttpException;

View File

@@ -0,0 +1,243 @@
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../auth/auth';
import {
addComment,
createArticle,
deleteArticle,
deleteComment,
favoriteArticle,
getArticle,
getArticles,
getCommentsByArticle,
getFeed,
unfavoriteArticle,
updateArticle,
} from './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.auth?.user?.id);
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.auth?.user?.id,
);
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.auth?.user?.id);
res.status(201).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.auth?.user?.id);
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.auth?.user?.id);
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, req.auth?.user!.id);
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.auth?.user?.id);
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.auth?.user?.id);
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.auth?.user?.id);
res.status(200).json({});
} 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.auth?.user?.id);
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.auth?.user?.id);
res.json({ article });
} catch (error) {
next(error);
}
},
);
export default router;

View File

@@ -0,0 +1,16 @@
import authorMapper from './author.mapper';
const articleMapper = (article: any, id?: number) => ({
slug: article.slug,
title: article.title,
description: article.description,
body: article.body,
tagList: article.tagList.map((tag: any) => tag.name),
createdAt: article.createdAt,
updatedAt: article.updatedAt,
favorited: article.favoritedBy.some((item: any) => item.id === id),
favoritesCount: article.favoritedBy.length,
author: authorMapper(article.author, id),
});
export default articleMapper;

View File

@@ -0,0 +1,10 @@
import { Comment } from './comment.model';
export interface Article {
id: number;
title: string;
slug: string;
description: string;
comments: Comment[];
favorited: boolean;
}

View File

@@ -0,0 +1,652 @@
import slugify from 'slugify';
import prisma from '../../../prisma/prisma-client';
import HttpException from '../../models/http-exception.model';
import profileMapper from '../profile/profile.utils';
import articleMapper from './article.mapper';
import { Tag } from '../tag/tag.model';
const buildFindAllQuery = (query: any, id: number | undefined) => {
const queries: any = [];
const orAuthorQuery = [];
const andAuthorQuery = [];
orAuthorQuery.push({
demo: {
equals: true,
},
});
if (id) {
orAuthorQuery.push({
id: {
equals: id,
},
});
}
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, id?: number) => {
const andQueries = buildFindAllQuery(query, id);
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((article: any) => articleMapper(article, id)),
articlesCount,
};
};
export const getFeed = async (offset: number, limit: number, id: number) => {
const articlesCount = await prisma.article.count({
where: {
author: {
followedBy: { some: { id: id } },
},
},
});
const articles = await prisma.article.findMany({
where: {
author: {
followedBy: { some: { id: 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((article: any) => articleMapper(article, id)),
articlesCount,
};
};
export const createArticle = async (article: any, id: number) => {
const { title, description, body, tagList } = article;
const tags = Array.isArray(tagList) ? tagList : [];
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 slug = `${slugify(title)}-${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: articleId,
...createdArticle
} = await prisma.article.create({
data: {
title,
description,
body,
slug,
tagList: {
connectOrCreate: tags.map((tag: string) => ({
create: { name: tag },
where: { name: tag },
})),
},
author: {
connect: {
id: id,
},
},
},
include: {
tagList: {
select: {
name: true,
},
},
author: {
select: {
username: true,
bio: true,
image: true,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return articleMapper(createdArticle, id);
};
export const getArticle = async (slug: string, id?: number) => {
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,
},
},
},
});
if (!article) {
throw new HttpException(404, { errors: { article: ['not found'] } });
}
return articleMapper(article, id);
};
const disconnectArticlesTags = async (slug: string) => {
await prisma.article.update({
where: {
slug,
},
data: {
tagList: {
set: [],
},
},
});
};
export const updateArticle = async (article: any, slug: string, id: number) => {
let newSlug = null;
const existingArticle = await await prisma.article.findFirst({
where: {
slug,
},
select: {
author: {
select: {
id: true,
username: true,
},
},
},
});
if (!existingArticle) {
throw new HttpException(404, {});
}
if (existingArticle.author.id !== id) {
throw new HttpException(403, {
message: 'You are not authorized to update this article',
});
}
if (article.title) {
newSlug = `${slugify(article.title)}-${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 =
Array.isArray(article.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,
followedBy: true,
},
},
favoritedBy: true,
_count: {
select: {
favoritedBy: true,
},
},
},
});
return articleMapper(updatedArticle, id);
};
export const deleteArticle = async (slug: string, id: number) => {
const existingArticle = await await prisma.article.findFirst({
where: {
slug,
},
select: {
author: {
select: {
id: true,
username: true,
},
},
},
});
if (!existingArticle) {
throw new HttpException(404, {});
}
if (existingArticle.author.id !== id) {
throw new HttpException(403, {
message: 'You are not authorized to delete this article',
});
}
await prisma.article.delete({
where: {
slug,
},
});
};
export const getCommentsByArticle = async (slug: string, id?: number) => {
const queries = [];
queries.push({
author: {
demo: true,
},
});
if (id) {
queries.push({
author: {
id,
},
});
}
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: any) => ({
...comment,
author: {
username: comment.author.username,
bio: comment.author.bio,
image: comment.author.image,
following: comment.author.followedBy.some((follow: any) => follow.id === id),
},
}));
return result;
};
export const addComment = async (body: string, slug: string, id: number) => {
if (!body) {
throw new HttpException(422, { errors: { body: ["can't be blank"] } });
}
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: 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: any) => follow.id === id),
},
};
};
export const deleteComment = async (id: number, userId: number) => {
const comment = await prisma.comment.findFirst({
where: {
id,
author: {
id: userId,
},
},
select: {
author: {
select: {
id: true,
username: true,
},
},
},
});
if (!comment) {
throw new HttpException(404, {});
}
if (comment.author.id !== userId) {
throw new HttpException(403, {
message: 'You are not authorized to delete this comment',
});
}
await prisma.comment.delete({
where: {
id,
},
});
};
export const favoriteArticle = async (slugPayload: string, id: number) => {
const { _count, ...article } = await prisma.article.update({
where: {
slug: slugPayload,
},
data: {
favoritedBy: {
connect: {
id: 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, id),
tagList: article?.tagList.map((tag: Tag) => tag.name),
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
favoritesCount: _count?.favoritedBy,
};
return result;
};
export const unfavoriteArticle = async (slugPayload: string, id: number) => {
const { _count, ...article } = await prisma.article.update({
where: {
slug: slugPayload,
},
data: {
favoritedBy: {
disconnect: {
id: 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, id),
tagList: article?.tagList.map((tag: Tag) => tag.name),
favorited: article.favoritedBy.some((favorited: any) => favorited.id === id),
favoritesCount: _count?.favoritedBy,
};
return result;
};

View File

@@ -0,0 +1,12 @@
import { User } from '../auth/user.model';
const authorMapper = (author: any, id?: number) => ({
username: author.username,
bio: author.bio,
image: author.image,
following: id
? author?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
: false,
});
export default authorMapper;

View File

@@ -0,0 +1,9 @@
import { Article } from './article.model';
export interface Comment {
id: number;
createdAt: Date;
updatedAt: Date;
body: string;
article?: Article;
}

View File

@@ -0,0 +1,70 @@
import { NextFunction, Request, Response, Router } from 'express';
import auth from './auth';
import { createUser, getCurrentUser, login, updateUser } from './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, demo: false });
res.status(201).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.auth?.user?.id);
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.auth?.user?.id);
res.json({ user });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,183 @@
import * as bcrypt from 'bcryptjs';
import { RegisterInput } from './register-input.model';
import prisma from '../../../prisma/prisma-client';
import HttpException from '../../models/http-exception.model';
import { RegisteredUser } from './registered-user.model';
import generateToken from './token.utils';
import { User } from './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, demo } = 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 } : {}),
...(demo ? { demo } : {}),
},
select: {
id: true,
email: true,
username: true,
bio: true,
image: true,
},
});
return {
...user,
token: generateToken(user.id),
};
};
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: {
id: true,
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.id),
};
}
}
throw new HttpException(403, {
errors: {
'email or password': ['is invalid'],
},
});
};
export const getCurrentUser = async (id: number) => {
const user = (await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
email: true,
username: true,
bio: true,
image: true,
},
})) as User;
return {
...user,
token: generateToken(user.id),
};
};
export const updateUser = async (userPayload: any, id: number) => {
const { email, username, password, image, bio } = userPayload;
let hashedPassword;
if (password) {
hashedPassword = await bcrypt.hash(password, 10);
}
const user = await prisma.user.update({
where: {
id: id,
},
data: {
...(email ? { email } : {}),
...(username ? { username } : {}),
...(password ? { password: hashedPassword } : {}),
...(image ? { image } : {}),
...(bio ? { bio } : {}),
},
select: {
id: true,
email: true,
username: true,
bio: true,
image: true,
},
});
return {
...user,
token: generateToken(user.id),
};
};

View File

@@ -0,0 +1,28 @@
import { expressjwt as jwt } from 'express-jwt';
import * as express from 'express';
const getTokenFromHeaders = (req: express.Request): 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;

View File

@@ -0,0 +1,8 @@
export interface RegisterInput {
email: string;
username: string;
password: string;
image?: string;
bio?: string;
demo?: boolean;
}

View File

@@ -0,0 +1,8 @@
export interface RegisteredUser {
id: number;
email: string;
username: string;
bio: string | null;
image: string | null;
token: string;
}

View File

@@ -0,0 +1,8 @@
import * as jwt from 'jsonwebtoken';
const generateToken = (id: number): string =>
jwt.sign({ user: { id } }, process.env.JWT_SECRET || 'superSecret', {
expiresIn: '60d',
});
export default generateToken;

9
src/app/routes/auth/user-request.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare namespace Express {
export interface Request {
auth?: {
user?: {
id?: number;
};
};
}
}

View File

@@ -0,0 +1,17 @@
import { Article } from '../article/article.model';
import { Comment } from '../article/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[];
demo: boolean;
}

View File

@@ -0,0 +1,67 @@
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../auth/auth';
import { followUser, getProfile, unfollowUser } from './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.auth?.user?.id);
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.auth?.user?.id);
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.auth?.user?.id);
res.json({ profile });
} catch (error) {
next(error);
}
},
);
export default router;

View File

@@ -0,0 +1,6 @@
export interface Profile {
username: string;
bio: string;
image: string;
following: boolean;
}

View File

@@ -0,0 +1,60 @@
import prisma from '../../../prisma/prisma-client';
import profileMapper from './profile.utils';
import HttpException from '../../models/http-exception.model';
export const getProfile = async (usernamePayload: string, id?: number) => {
const profile = await prisma.user.findUnique({
where: {
username: usernamePayload,
},
include: {
followedBy: true,
},
});
if (!profile) {
throw new HttpException(404, {});
}
return profileMapper(profile, id);
};
export const followUser = async (usernamePayload: string, id: number) => {
const profile = await prisma.user.update({
where: {
username: usernamePayload,
},
data: {
followedBy: {
connect: {
id,
},
},
},
include: {
followedBy: true,
},
});
return profileMapper(profile, id);
};
export const unfollowUser = async (usernamePayload: string, id: number) => {
const profile = await prisma.user.update({
where: {
username: usernamePayload,
},
data: {
followedBy: {
disconnect: {
id,
},
},
},
include: {
followedBy: true,
},
});
return profileMapper(profile, id);
};

View File

@@ -0,0 +1,13 @@
import { User } from '../auth/user.model';
import { Profile } from './profile.model';
const profileMapper = (user: any, id: number | undefined): Profile => ({
username: user.username,
bio: user.bio,
image: user.image,
following: id
? user?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
: false,
});
export default profileMapper;

13
src/app/routes/routes.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import tagsController from './tag/tag.controller';
import articlesController from './article/article.controller';
import authController from './auth/auth.controller';
import profileController from './profile/profile.controller';
const api = Router()
.use(tagsController)
.use(articlesController)
.use(profileController)
.use(authController);
export default Router().use('/api', api);

View File

@@ -0,0 +1,22 @@
import { NextFunction, Request, Response, Router } from 'express';
import auth from '../auth/auth';
import getTags from './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.auth?.user?.id);
res.json({ tags });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,3 @@
export interface Tag {
name: string;
}

View File

@@ -0,0 +1,40 @@
import prisma from '../../../prisma/prisma-client';
import { Tag } from './tag.model';
const getTags = async (id?: number): Promise<string[]> => {
const queries = [];
queries.push({ demo: true });
if (id) {
queries.push({
id: {
equals: id,
},
});
}
const tags = await prisma.tag.findMany({
where: {
articles: {
some: {
author: {
OR: queries,
},
},
},
},
select: {
name: true,
},
orderBy: {
articles: {
_count: 'desc',
},
},
take: 10,
});
return tags.map((tag: Tag) => tag.name);
};
export default getTags;

57
src/main.ts Normal file
View File

@@ -0,0 +1,57 @@
import express from 'express';
import cors from 'cors';
import * as bodyParser from 'body-parser';
import routes from './app/routes/routes';
import HttpException from './app/models/http-exception.model';
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(__dirname + '/assets'));
app.get('/', (req: express.Request, res: express.Response) => {
res.json({ status: 'API is running on /api' });
});
/* eslint-disable */
app.use(
(
err: Error | HttpException,
req: express.Request,
res: express.Response,
next: express.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}`);
});

View File

@@ -0,0 +1,114 @@
-- 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,
"demo" BOOLEAN NOT NULL DEFAULT false,
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;

View File

@@ -0,0 +1,36 @@
/*
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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ALTER COLUMN "image" SET DEFAULT E'https://api.realworld.io/images/smiley-cyrus.jpeg';

View File

@@ -0,0 +1,11 @@
-- RenameIndex
ALTER INDEX "Article.slug_unique" RENAME TO "Article_slug_key";
-- RenameIndex
ALTER INDEX "Tag.name_unique" RENAME TO "Tag_name_key";
-- RenameIndex
ALTER INDEX "User.email_unique" RENAME TO "User_email_key";
-- RenameIndex
ALTER INDEX "User.username_unique" RENAME TO "User_username_key";

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client';
declare global {
namespace NodeJS {
interface Global {}
}
}
// add prisma to the NodeJS global type
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;

56
src/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,56 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
previewFeatures = []
}
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], onDelete: Cascade, references: [id])
authorId Int
favoritedBy User[] @relation("UserFavorites")
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")
followedBy User[] @relation("UserFollows")
following User[] @relation("UserFollows")
comments Comment[]
demo Boolean @default(false)
}

66
src/prisma/seed.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
randEmail,
randFullName,
randLines,
randParagraph,
randPassword, randPhrase,
randWord
} from '@ngneat/falso';
import { PrismaClient } from '@prisma/client';
import { RegisteredUser } from '../app/routes/auth/registered-user.model';
import { createUser } from '../app/routes/auth/auth.service';
import { addComment, createArticle } from '../app/routes/article/article.service';
const prisma = new PrismaClient();
export const generateUser = async (): Promise<RegisteredUser> =>
createUser({
username: randFullName(),
email: randEmail(),
password: randPassword(),
image: 'https://api.realworld.io/images/demo-avatar.png',
demo: true,
});
export const generateArticle = async (id: number) =>
createArticle(
{
title: randPhrase(),
description: randParagraph(),
body: randLines({ length: 10 }).join(' '),
tagList: randWord({ length: 4 }),
},
id,
);
export const generateComment = async (id: number, slug: string) =>
addComment(randParagraph(), slug, id);
const main = async () => {
try {
const users = await Promise.all(Array.from({length: 12}, () => generateUser()));
users?.map(user => user);
// eslint-disable-next-line no-restricted-syntax
for await (const user of users) {
const articles = await Promise.all(Array.from({length: 12}, () => generateArticle(user.id)));
// eslint-disable-next-line no-restricted-syntax
for await (const article of articles) {
await Promise.all(users.map(userItem => generateComment(userItem.id, article.slug)));
}
}
} catch (e) {
console.error(e);
}
};
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async () => {
await prisma.$disconnect();
process.exit(1);
});

17
src/tests/prisma-mock.ts Normal file
View File

@@ -0,0 +1,17 @@
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
import prisma from '../prisma/prisma-client';
import { PrismaClient } from '@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;

View File

@@ -0,0 +1,144 @@
import prismaMock from '../prisma-mock';
import {
deleteComment,
favoriteArticle,
unfavoriteArticle,
} from '../../app/routes/article/article.service';
describe('ArticleService', () => {
describe('deleteComment', () => {
test('should throw an error ', () => {
// Given
const id = 123;
const idUser = 456;
// When
// @ts-ignore
prismaMock.comment.findFirst.mockResolvedValue(null);
// Then
expect(deleteComment(id, idUser)).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: '',
demo: false,
};
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
// @ts-ignore
prismaMock.user.findUnique.mockResolvedValue(mockedUserResponse);
// @ts-ignore
prismaMock.article.update.mockResolvedValue(mockedArticleResponse);
// Then
await expect(favoriteArticle(slug, mockedUserResponse.id)).resolves.toHaveProperty(
'favoritesCount',
);
});
test('should throw an error if no user is found', async () => {
// Given
const id = 123;
const slug = 'how-to-train-your-dragon';
const username = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(favoriteArticle(slug, id)).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: '',
demo: false,
};
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, mockedUserResponse.id)).resolves.toHaveProperty(
'favoritesCount',
);
});
test('should throw an error if no user is found', async () => {
// Given
const id = 123;
const slug = 'how-to-train-your-dragon';
const username = 'RealWorld';
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(unfavoriteArticle(slug, id)).rejects.toThrowError();
});
});
});

View File

@@ -0,0 +1,254 @@
import * as bcrypt from 'bcryptjs';
import { createUser, getCurrentUser, login, updateUser } from '../../app/routes/auth/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: '',
demo: false,
};
// When
// @ts-ignore
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: '',
demo: false,
};
// 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: '',
demo: false,
};
// 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: '',
demo: false,
};
// 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 id = 123;
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
await expect(getCurrentUser(id)).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: '',
demo: false,
};
// When
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(updateUser(user, user.id)).resolves.toHaveProperty('token');
});
});
});

View File

@@ -0,0 +1,145 @@
import prismaMock from '../prisma-mock';
import { followUser, getProfile, unfollowUser } from '../../app/routes/profile/profile.service';
describe('ProfileService', () => {
describe('getProfile', () => {
test('should return a following property', async () => {
// Given
const username = 'RealWorld';
const id = 123;
const mockedResponse = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
followedBy: [],
};
// When
// @ts-ignore
prismaMock.user.findUnique.mockResolvedValue(mockedResponse);
// Then
await expect(getProfile(username, id)).resolves.toHaveProperty('following');
});
test('should throw an error if no user is found', async () => {
// Given
const username = 'RealWorld';
const id = 123;
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(getProfile(username, id)).rejects.toThrowError();
});
});
describe('followUser', () => {
test('shoud return a following property', async () => {
// Given
const usernamePayload = 'AnotherUser';
const id = 123;
const mockedAuthUser = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
followedBy: [],
};
const mockedResponse = {
id: 123,
username: 'AnotherUser',
email: 'another@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
followedBy: [],
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(followUser(usernamePayload, id)).resolves.toHaveProperty('following');
});
test('shoud throw an error if no user is found', async () => {
// Given
const usernamePayload = 'AnotherUser';
const id = 123;
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(followUser(usernamePayload, id)).rejects.toThrowError();
});
});
describe('unfollowUser', () => {
test('shoud return a following property', async () => {
// Given
const usernamePayload = 'AnotherUser';
const id = 123;
const mockedAuthUser = {
id: 123,
username: 'RealWorld',
email: 'realworld@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
followedBy: [],
};
const mockedResponse = {
id: 123,
username: 'AnotherUser',
email: 'another@me',
password: '1234',
bio: null,
image: null,
token: '',
demo: false,
followedBy: [],
};
// When
prismaMock.user.findUnique.mockResolvedValue(mockedAuthUser);
prismaMock.user.update.mockResolvedValue(mockedResponse);
// Then
await expect(unfollowUser(usernamePayload, id)).resolves.toHaveProperty('following');
});
test('shoud throw an error if no user is found', async () => {
// Given
const usernamePayload = 'AnotherUser';
const id = 123;
// When
prismaMock.user.findUnique.mockResolvedValue(null);
// Then
await expect(unfollowUser(usernamePayload, id)).rejects.toThrowError();
});
});
});

View File

@@ -0,0 +1,6 @@
describe('TagService', () => {
describe('getTags', () => {
// TODO : prismaMock.tag.groupBy.mockResolvedValue(mockedResponse) doesn't work
test.todo('should return a list of strings');
});
});

View File

@@ -0,0 +1,79 @@
import profileMapper from '../../app/routes/profile/profile.utils';
describe('ProfileUtils', () => {
describe('profileMapper', () => {
test('should return a profile', () => {
// Given
const user = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
followedBy: [],
};
const id = 123;
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: false,
};
// Then
expect(profileMapper(user, id)).toEqual(expected);
});
test('should return a profile followed by the user', () => {
// Given
const user = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
followedBy: [
{
id: 123,
},
],
};
const id = 123;
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: true,
};
// Then
expect(profileMapper(user, id)).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 id = 123;
// When
const expected = {
username: 'RealWorld',
bio: 'My happy life',
image: null,
following: false,
};
// Then
expect(profileMapper(user, id)).toEqual(expected);
});
});
});

17
tsconfig.app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"module": "commonjs",
"types": ["node"]
},
"exclude": [
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/tests/**/*",
],
"include": [
"src/**/*.ts"
]
}

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"rootDir": ".",
"sourceMap": true,
"declaration": false,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "es2015",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": "./api",
"paths": {},
"esModuleInterop": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"exclude": ["node_modules", "tmp"]
}

14
tsconfig.spec.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}