Skip to content

Commit

Permalink
feat: add article apis (#64)
Browse files Browse the repository at this point in the history
  • Loading branch information
khuongln-1346 authored Nov 16, 2024
1 parent 79c0206 commit 2ff7209
Show file tree
Hide file tree
Showing 34 changed files with 525 additions and 140 deletions.
1 change: 1 addition & 0 deletions apps/admin-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"nestjs-i18n": "^10.5.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"slugify": "^1.6.6",
"typeorm": "^0.3.20"
},
"devDependencies": {
Expand Down
30 changes: 25 additions & 5 deletions apps/admin-api/src/api/article/article.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
SerializeOptions,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthOptional, CurrentUser } from '@repo/api';
import { ArticleService } from './article.service';
import { ArticleListReqDto, ArticleListResDto } from './dto/article-list.dto';
import { ArticleResDto } from './dto/article.dto';
import { CreateArticleReqDto } from './dto/create-article.dto';

@ApiTags('Article')
@Controller('articles')
export class ArticleController {
constructor(private readonly articleService: ArticleService) {}

@Get()
async list() {
return await this.articleService.list();
@AuthOptional()
@SerializeOptions({ type: ArticleListResDto })
async list(@Query() reqDto: ArticleListReqDto): Promise<ArticleListResDto> {
return await this.articleService.list(reqDto);
}

@Get('feed')
Expand All @@ -23,8 +39,12 @@ export class ArticleController {
}

@Post()
async create() {
return await this.articleService.create();
@SerializeOptions({ type: ArticleResDto })
async create(
@CurrentUser('id') userId: number,
@Body('article') articleData: CreateArticleReqDto,
): Promise<ArticleResDto> {
return await this.articleService.create(userId, articleData);
}

@Put(':slug')
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-api/src/api/article/article.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticleEntity } from '@repo/database-typeorm';
import { ArticleEntity, TagEntity } from '@repo/database-typeorm';
import { ArticleController } from './article.controller';
import { ArticleService } from './article.service';
import { CommentModule } from './comment/comment.module';
Expand All @@ -12,7 +12,7 @@ import { FavoriteModule } from './favorite/favorite.module';
imports: [
CommentModule,
FavoriteModule,
TypeOrmModule.forFeature([ArticleEntity]),
TypeOrmModule.forFeature([ArticleEntity, TagEntity]),
],
})
export class ArticleModule {}
13 changes: 12 additions & 1 deletion apps/admin-api/src/api/article/article.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ArticleEntity } from '@repo/database-typeorm';
import { ArticleEntity, TagEntity } from '@repo/database-typeorm';
import { Repository } from 'typeorm';
import { ArticleService } from './article.service';

Expand All @@ -9,19 +9,30 @@ describe('ArticleService', () => {
let articleRepositoryValue: Partial<
Record<keyof Repository<ArticleEntity>, jest.Mock>
>;
let tagRepositoryValue: Partial<
Record<keyof Repository<TagEntity>, jest.Mock>
>;

beforeAll(async () => {
articleRepositoryValue = {
findOne: jest.fn(),
};

tagRepositoryValue = {
find: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
ArticleService,
{
provide: getRepositoryToken(ArticleEntity),
useValue: articleRepositoryValue,
},
{
provide: getRepositoryToken(TagEntity),
useValue: tagRepositoryValue,
},
],
}).compile();

Expand Down
124 changes: 117 additions & 7 deletions apps/admin-api/src/api/article/article.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,65 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ArticleEntity } from '@repo/database-typeorm';
import { Repository } from 'typeorm';
import { paginate } from '@repo/api/utils/offset-pagination';
import { ArticleEntity, TagEntity } from '@repo/database-typeorm';
import slugify from 'slugify';
import { In, Repository } from 'typeorm';
import { ArticleListReqDto, ArticleListResDto } from './dto/article-list.dto';
import { ArticleDto, ArticleResDto } from './dto/article.dto';
import { CreateArticleReqDto } from './dto/create-article.dto';

@Injectable()
export class ArticleService {
private readonly logger = new Logger(ArticleService.name);
constructor(
@InjectRepository(ArticleEntity)
private readonly articleRepository: Repository<ArticleEntity>,
@InjectRepository(TagEntity)
private readonly tagRepository: Repository<TagEntity>,
) {}

async list() {
this.articleRepository.find();
async list(reqDto: ArticleListReqDto): Promise<ArticleListResDto> {
const qb = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.tags', 'tags')
.leftJoinAndSelect('article.favoritedBy', 'favoritedBy');

qb.where('1 = 1');

if (reqDto.tag) {
qb.where('tags.name LIKE :tag', { tag: `%${reqDto.tag}%` });
}

if (reqDto.author) {
qb.andWhere('author.username = :author', { author: reqDto.author });
}

if (reqDto.favorited) {
qb.andWhere('favoritedBy.username = :favorited', {
favorited: reqDto.favorited,
});
}

qb.orderBy('article.createdAt', 'DESC');

const [articles, metaDto] = await paginate<ArticleEntity>(qb, reqDto, {
skipCount: false,
takeAll: false,
});

const articleDtos = articles.map((article) => {
const articleDto = article.toDto(ArticleDto);
delete articleDto.body;
articleDto.tagList = article.tags.map((tag) => tag.name);
return articleDto;
});

return {
articles: articleDtos,
articlesCount: metaDto.totalRecords,
pagination: metaDto,
};
}

async feed() {
Expand All @@ -22,8 +70,40 @@ export class ArticleService {
throw new Error('Method not implemented.');
}

async create() {
throw new Error('Method not implemented.');
async create(
userId: number,
articleData: CreateArticleReqDto,
): Promise<ArticleResDto> {
const { title, description, body, tagList } = articleData;
const slug = await this.validateAndCreateSlug(title);
const { existingTags, newTags } = await this.prepareTags(tagList);

let savedArticle: ArticleEntity;
await this.articleRepository.manager.transaction(async (manager) => {
// Save new tags
const savedNewTags = await manager.save(newTags);
const allTags = [...existingTags, ...savedNewTags];

// Save article
const newArticle = new ArticleEntity({
title,
slug,
description,
body,
authorId: userId,
tags: allTags,
});
savedArticle = await manager.save(newArticle);
});

return {
article: {
title: savedArticle.title,
description: savedArticle.description,
body: savedArticle.body,
tagList: savedArticle.tags.map((tag) => tag.name),
},
};
}

async delete(_slug: string) {
Expand All @@ -33,4 +113,34 @@ export class ArticleService {
async update(_slug: string) {
throw new Error('Method not implemented.');
}

private async validateAndCreateSlug(title: string) {
const slug = slugify(title);
const existingArticle = await this.articleRepository.findOne({
where: { slug },
});

if (existingArticle) {
return `${slug}-${Date.now()}`;
}

return slug;
}

private async prepareTags(tagList: string[]) {
const existingTags = await this.tagRepository.find({
where: { name: In(tagList) },
});

const existingTagNames = existingTags.map((tag) => tag.name);

const newTagNames = tagList.filter(
(tag) => !existingTagNames.includes(tag),
);
const newTags = this.tagRepository.create(
newTagNames.map((name) => ({ name })),
);

return { existingTags, newTags };
}
}
38 changes: 37 additions & 1 deletion apps/admin-api/src/api/article/dto/article-list.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { NumberFieldOptional, StringFieldOptional } from '@repo/api';
import { OffsetPaginationDto } from '@repo/api/dto/offset-pagination/offset-pagination.dto';
import { PageOptionsDto } from '@repo/api/dto/offset-pagination/page-options.dto';
import { Expose, Type } from 'class-transformer';
import { ArticleDto } from './article.dto';

export class ArticleListDto {
export class ArticleListReqDto extends PageOptionsDto {
@StringFieldOptional()
readonly tag?: string;

@StringFieldOptional()
readonly author?: string;

@StringFieldOptional()
readonly favorited?: string;

@NumberFieldOptional({
minimum: 1,
default: 20,
int: true,
})
override readonly limit: number = 20;

@NumberFieldOptional({
minimum: 0,
default: 0,
int: true,
})
override readonly offset: number = 0;
}

export class ArticleListResDto {
@ApiProperty({ type: [ArticleDto] })
@Expose()
@Type(() => ArticleDto)
articles: ArticleDto[];

@ApiProperty()
@Expose()
articlesCount: number;

@ApiProperty({ type: OffsetPaginationDto })
@Expose()
@Type(() => OffsetPaginationDto)
pagination: OffsetPaginationDto;
}
14 changes: 7 additions & 7 deletions apps/admin-api/src/api/article/dto/article.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Expose, Type } from 'class-transformer';

export class ArticleDto {
@Expose()
slug: string;
slug?: string;

@Expose()
title: string;
Expand All @@ -12,26 +12,26 @@ export class ArticleDto {
description: string;

@Expose()
body?: string;
body: string;

@Expose()
tagList: string[];

@Expose()
createdAt: Date;
createdAt?: Date;

@Expose()
updatedAt: Date;
updatedAt?: Date;

@Expose()
favorited: boolean;
favorited?: boolean;

@Expose()
favoritesCount: number;
favoritesCount?: number;

@Expose()
@Type(() => ProfileDto)
author: ProfileDto;
author?: ProfileDto;
}

export class ArticleResDto {
Expand Down
20 changes: 20 additions & 0 deletions apps/admin-api/src/api/article/dto/create-article.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StringField, StringFieldOptional } from '@repo/api';

export class CreateArticleReqDto {
@ApiProperty()
@StringField()
title: string;

@ApiProperty()
@StringField()
description: string;

@ApiProperty()
@StringField()
body: string;

@ApiPropertyOptional()
@StringFieldOptional({ each: true })
tagList?: string[];
}
3 changes: 2 additions & 1 deletion apps/admin-api/src/api/profile/profile.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '@repo/database-typeorm';
import { UserFollowsEntity } from '@repo/database-typeorm/entities/user-follows.entity';
import { ProfileController } from './profile.controller';
import { ProfileService } from './profile.service';

@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
imports: [TypeOrmModule.forFeature([UserEntity, UserFollowsEntity])],
controllers: [ProfileController],
providers: [ProfileService],
})
Expand Down
Loading

0 comments on commit 2ff7209

Please sign in to comment.