Skip to content

Commit

Permalink
feat: add some article apis: update, get, delete article (#68)
Browse files Browse the repository at this point in the history
  • Loading branch information
khuongln-1346 authored Nov 18, 2024
1 parent 155116c commit 55a49f9
Show file tree
Hide file tree
Showing 18 changed files with 228 additions and 36 deletions.
51 changes: 45 additions & 6 deletions apps/admin-api/src/api/article/article.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ArticleFeedReqDto } from './dto/article-feed.dto';
import { ArticleListReqDto, ArticleListResDto } from './dto/article-list.dto';
import { ArticleResDto } from './dto/article.dto';
import { CreateArticleReqDto } from './dto/create-article.dto';
import { UpdateArticleReqDto } from './dto/update-article.dto';

@ApiTags('Article')
@Controller('articles')
Expand All @@ -35,16 +36,30 @@ export class ArticleController {
}

@Get('feed')
async feed(
@SerializeOptions({ type: ArticleListResDto })
@ApiAuth({
summary: 'Feed Articles',
type: ArticleListResDto,
})
async getFeed(
@CurrentUser('id') userId: number,
@Query() reqDto: ArticleFeedReqDto,
): Promise<ArticleListResDto> {
return await this.articleService.feed(userId, reqDto);
return await this.articleService.getFeed(userId, reqDto);
}

@Get(':slug')
async get(@Param('slug') slug: string) {
return await this.articleService.get(slug);
@SerializeOptions({ type: ArticleResDto })
@ApiAuth({
summary: 'Get Article',
type: ArticleResDto,
isAuthOptional: true,
})
async get(
@CurrentUser('id') userId: number,
@Param('slug') slug: string,
): Promise<ArticleResDto> {
return await this.articleService.get(userId, slug);
}

@Post()
Expand Down Expand Up @@ -74,11 +89,35 @@ export class ArticleController {
}

@Put(':slug')
async update(@Param('slug') slug: string) {
return await this.articleService.update(slug);
@SerializeOptions({ type: ArticleResDto })
@ApiAuth({
summary: 'Update Article',
type: ArticleResDto,
})
@ApiBody({
description: 'Article update request',
schema: {
type: 'object',
properties: {
article: {
type: 'object',
$ref: '#/components/schemas/UpdateArticleReqDto',
},
},
required: ['article'],
},
})
async update(
@Param('slug') slug: string,
@Body('article') articleData: UpdateArticleReqDto,
) {
return await this.articleService.update(slug, articleData);
}

@Delete(':slug')
@ApiAuth({
summary: 'Delete Article',
})
async delete(@Param('slug') slug: string) {
return await this.articleService.delete(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, TagEntity } from '@repo/database-typeorm';
import { ArticleEntity, TagEntity, UserEntity } 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, TagEntity]),
TypeOrmModule.forFeature([ArticleEntity, TagEntity, UserEntity]),
],
})
export class ArticleModule {}
23 changes: 22 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,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ArticleEntity, TagEntity } from '@repo/database-typeorm';
import { ArticleEntity, TagEntity, UserEntity } from '@repo/database-typeorm';
import { I18nService } from 'nestjs-i18n';
import { Repository } from 'typeorm';
import { ArticleService } from './article.service';

Expand All @@ -12,6 +13,10 @@ describe('ArticleService', () => {
let tagRepositoryValue: Partial<
Record<keyof Repository<TagEntity>, jest.Mock>
>;
let userRepositoryValue: Partial<
Record<keyof Repository<UserEntity>, jest.Mock>
>;
let i18nServiceValue: Partial<Record<keyof I18nService, jest.Mock>>;

beforeAll(async () => {
articleRepositoryValue = {
Expand All @@ -22,6 +27,14 @@ describe('ArticleService', () => {
find: jest.fn(),
};

userRepositoryValue = {
findOne: jest.fn(),
};

i18nServiceValue = {
t: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
ArticleService,
Expand All @@ -33,6 +46,14 @@ describe('ArticleService', () => {
provide: getRepositoryToken(TagEntity),
useValue: tagRepositoryValue,
},
{
provide: getRepositoryToken(UserEntity),
useValue: userRepositoryValue,
},
{
provide: I18nService,
useValue: i18nServiceValue,
},
],
}).compile();

Expand Down
133 changes: 117 additions & 16 deletions apps/admin-api/src/api/article/article.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { ErrorCode } from '@/constants/error-code.constant';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { paginate } from '@repo/api/utils/offset-pagination';
import { ArticleEntity, TagEntity } from '@repo/database-typeorm';
import { ArticleEntity, TagEntity, UserEntity } from '@repo/database-typeorm';
import { I18nService } from 'nestjs-i18n';
import slugify from 'slugify';
import { In, Repository } from 'typeorm';
import { ArticleFeedReqDto } from './dto/article-feed.dto';
import { ArticleListReqDto, ArticleListResDto } from './dto/article-list.dto';
import { ArticleDto, ArticleResDto } from './dto/article.dto';
import { CreateArticleReqDto } from './dto/create-article.dto';
import { UpdateArticleReqDto } from './dto/update-article.dto';

@Injectable()
export class ArticleService {
Expand All @@ -17,6 +20,9 @@ export class ArticleService {
private readonly articleRepository: Repository<ArticleEntity>,
@InjectRepository(TagEntity)
private readonly tagRepository: Repository<TagEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private readonly i18n: I18nService,
) {}

async list(reqDto: ArticleListReqDto): Promise<ArticleListResDto> {
Expand All @@ -29,7 +35,7 @@ export class ArticleService {
qb.where('1 = 1');

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

if (reqDto.author) {
Expand Down Expand Up @@ -63,17 +69,47 @@ export class ArticleService {
};
}

async feed(
async getFeed(
userId: number,
reqDto: ArticleFeedReqDto,
): Promise<ArticleListResDto> {
const qb = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author');
const userWithFollowing = await this.userRepository.findOne({
select: {
id: true,
following: {
id: true,
followeeId: true,
},
},
where: { id: userId },
relations: ['following'],
});

qb.where('1 = 1');
const followeeIds =
userWithFollowing?.following?.map((f) => f.followeeId) || [];

qb.orderBy('article.createdAt', 'DESC');
if (!followeeIds.length) {
return {
articles: [],
articlesCount: 0,
pagination: {
limit: 0,
offset: 0,
totalRecords: 0,
totalPages: 0,
},
};
}

const qb = this.articleRepository
.createQueryBuilder('article')
.leftJoinAndSelect('article.author', 'author')
.leftJoinAndSelect('article.tags', 'tags')
.where('article.authorId <> :userId', { userId })
.andWhere('article.authorId IN (:...followeeIds)', {
followeeIds,
})
.orderBy('article.createdAt', 'DESC');

const [articles, metaDto] = await paginate<ArticleEntity>(qb, reqDto, {
skipCount: false,
Expand All @@ -94,8 +130,24 @@ export class ArticleService {
};
}

async get(_slug: string) {
throw new Error('Method not implemented.');
async get(userId: number, slug: string): Promise<ArticleResDto> {
const article = await this.articleRepository.findOne({
where: { slug: slug },
relations: ['author', 'tags', 'favoritedBy'],
});

if (!article) {
throw new NotFoundException(this.i18n.t(ErrorCode.E201));
}

return {
article: {
...article.toDto(ArticleDto),
tagList: article.tags.map((tag) => tag.name),
favorited: article.favoritedBy.some((user) => user.id === userId),
favoritesCount: article.favoritedBy.length,
},
};
}

async create(
Expand Down Expand Up @@ -135,16 +187,58 @@ export class ArticleService {
};
}

async delete(_slug: string) {
throw new Error('Method not implemented.');
async delete(slug: string) {
await this.articleRepository.delete({ slug: slug });
}

async update(_slug: string) {
throw new Error('Method not implemented.');
async update(reqSlug: string, articleData: UpdateArticleReqDto) {
const article = await this.articleRepository.findOne({
where: { slug: reqSlug },
});

if (!article) {
throw new NotFoundException(this.i18n.t(ErrorCode.E201));
}

const { title, description, body, tagList } = articleData;
const newSlug =
reqSlug !== this.generateSlug(title)
? await this.validateAndCreateSlug(title)
: reqSlug;
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 updatedArticle = Object.assign(article, {
title,
slug: newSlug,
description,
body,
tags: allTags,
});

savedArticle = await manager.save(updatedArticle);
});

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

private async validateAndCreateSlug(title: string) {
const slug = slugify(title);
const slug = this.generateSlug(title);

const existingArticle = await this.articleRepository.findOne({
where: { slug },
});
Expand All @@ -156,6 +250,13 @@ export class ArticleService {
return slug;
}

private generateSlug(title: string) {
return slugify(title, {
lower: true,
strict: true,
});
}

private async prepareTags(tagList: string[]) {
const existingTags = await this.tagRepository.find({
where: { name: In(tagList) },
Expand Down
6 changes: 5 additions & 1 deletion apps/admin-api/src/api/article/dto/article-feed.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { OmitType } from '@nestjs/swagger';
import { NumberFieldOptional } from '@repo/api';
import { PageOptionsDto } from '@repo/api/dto/offset-pagination/page-options.dto';

export class ArticleFeedReqDto extends PageOptionsDto {
export class ArticleFeedReqDto extends OmitType(PageOptionsDto, [
'order',
'q',
] as const) {
@NumberFieldOptional({
minimum: 1,
default: 20,
Expand Down
6 changes: 5 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,3 +1,4 @@
import { OmitType } from '@nestjs/swagger';
import {
ClassField,
NumberField,
Expand All @@ -8,7 +9,10 @@ import { OffsetPaginationDto } from '@repo/api/dto/offset-pagination/offset-pagi
import { PageOptionsDto } from '@repo/api/dto/offset-pagination/page-options.dto';
import { ArticleDto } from './article.dto';

export class ArticleListReqDto extends PageOptionsDto {
export class ArticleListReqDto extends OmitType(PageOptionsDto, [
'order',
'q',
] as const) {
@StringFieldOptional({ minLength: 0 })
readonly tag?: string;

Expand Down
4 changes: 4 additions & 0 deletions apps/admin-api/src/api/article/dto/update-article.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateArticleReqDto } from './create-article.dto';

export class UpdateArticleReqDto extends PartialType(CreateArticleReqDto) {}
4 changes: 2 additions & 2 deletions apps/admin-api/src/api/profile/dto/profile.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ClassField, StringField } from '@repo/api';
import { BooleanField, ClassField, StringField } from '@repo/api';

export class ProfileDto {
@StringField({ expose: true })
Expand All @@ -10,7 +10,7 @@ export class ProfileDto {
@StringField({ expose: true })
image: string;

@StringField({ expose: true })
@BooleanField({ expose: true })
following: boolean;
}

Expand Down
Loading

0 comments on commit 55a49f9

Please sign in to comment.