add news api
This commit is contained in:
272
src/modules/article/article.controller.ts
Normal file
272
src/modules/article/article.controller.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Controller } from "@/common/controller";
|
||||
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
|
||||
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
|
||||
import { paginationQuerySchema } from "@/common/schemas";
|
||||
import type { AbstractFileStorage } from "@/common/services/file-storage/abstract.file-storage";
|
||||
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
|
||||
import type {
|
||||
ErrorResponse,
|
||||
ListResponse,
|
||||
SingleResponse,
|
||||
} from "@/common/types";
|
||||
import { Article } from "@/database/entities/article.entity";
|
||||
import { Tag } from "@/database/entities/tag.entity";
|
||||
import { AdminPermission } from "@/database/enums/admin-permission.enum";
|
||||
import { orm } from "@/database/orm";
|
||||
import type { ArticleMapper } from "@/modules/article/article.mapper";
|
||||
import {
|
||||
articleParamsSchema,
|
||||
articleRequestSchema,
|
||||
} from "@/modules/article/article.schemas";
|
||||
import type { ArticleResponse } from "@/modules/article/article.types";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import slugify from "slugify";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export class ArticleController extends Controller {
|
||||
public constructor(
|
||||
private readonly mapper: ArticleMapper,
|
||||
private readonly fileStorage: AbstractFileStorage,
|
||||
private readonly jwtService: AbstractJwtService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
const parseBodyResult = articleRequestSchema.safeParse(req.body);
|
||||
if (!parseBodyResult.success) {
|
||||
return this.handleZodError(parseBodyResult.error, res, "body");
|
||||
}
|
||||
const body = parseBodyResult.data;
|
||||
|
||||
const thumbnailFile = await this.fileStorage.storeFile(
|
||||
Buffer.from(body.thumbnail, "base64"),
|
||||
);
|
||||
|
||||
const article = orm.em.create(Article, {
|
||||
id: ulid(),
|
||||
thumbnail: thumbnailFile.name,
|
||||
slug: slugify(body.title, { lower: true }),
|
||||
title: body.title,
|
||||
content: body.content,
|
||||
views: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
for (const [index, tagId] of body.tag_ids.entries()) {
|
||||
const tag = await orm.em.findOne(Tag, {
|
||||
id: tagId,
|
||||
});
|
||||
if (!tag) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: `tag_ids.${index}`,
|
||||
location: "body",
|
||||
message: "Tag not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
article.tags.add(tag);
|
||||
}
|
||||
|
||||
await orm.em.flush();
|
||||
|
||||
return res.status(201).json({
|
||||
data: this.mapper.mapEntityToResponse(article),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<ArticleResponse>);
|
||||
}
|
||||
|
||||
async list(req: Request, res: Response) {
|
||||
const parseQueryResult = paginationQuerySchema.safeParse(req.query);
|
||||
if (!parseQueryResult.success) {
|
||||
return this.handleZodError(parseQueryResult.error, res, "query");
|
||||
}
|
||||
const query = parseQueryResult.data;
|
||||
|
||||
const count = await orm.em.count(Article);
|
||||
|
||||
const articles = await orm.em.find(
|
||||
Article,
|
||||
{},
|
||||
{
|
||||
limit: query.per_page,
|
||||
offset: (query.page - 1) * query.per_page,
|
||||
orderBy: { createdAt: "DESC" },
|
||||
},
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
data: articles.map(this.mapper.mapEntityToResponse.bind(this.mapper)),
|
||||
errors: null,
|
||||
meta: {
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
total_pages: Math.ceil(count / query.per_page),
|
||||
total_items: count,
|
||||
},
|
||||
} satisfies ListResponse<ArticleResponse>);
|
||||
}
|
||||
|
||||
async view(req: Request, res: Response) {
|
||||
const parseParamsResult = articleParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const article = await orm.em.findOne(Article, { slug: params.slug });
|
||||
if (!article) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "slug",
|
||||
location: "params",
|
||||
message: "Article not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
article.views += 1;
|
||||
|
||||
await orm.em.flush();
|
||||
|
||||
return res.status(200).json({
|
||||
data: this.mapper.mapEntityToResponse(article),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<ArticleResponse>);
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response) {
|
||||
const parseParamsResult = articleParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const parseBodyResult = articleRequestSchema.safeParse(req.body);
|
||||
if (!parseBodyResult.success) {
|
||||
return this.handleZodError(parseBodyResult.error, res, "body");
|
||||
}
|
||||
const body = parseBodyResult.data;
|
||||
|
||||
const article = await orm.em.findOne(Article, { slug: params.slug });
|
||||
if (!article) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "slug",
|
||||
location: "params",
|
||||
message: "Article not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
await this.fileStorage.storeFile(
|
||||
Buffer.from(body.thumbnail, "base64"),
|
||||
article.thumbnail,
|
||||
);
|
||||
|
||||
article.slug = slugify(body.title, { lower: true });
|
||||
article.title = body.title;
|
||||
article.content = body.content;
|
||||
article.updatedAt = new Date();
|
||||
|
||||
article.tags.removeAll();
|
||||
for (const [index, tagId] of body.tag_ids.entries()) {
|
||||
const tag = await orm.em.findOne(Tag, {
|
||||
id: tagId,
|
||||
});
|
||||
if (!tag) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: `tag_ids.${index}`,
|
||||
location: "body",
|
||||
message: "Tag not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
article.tags.add(tag);
|
||||
}
|
||||
|
||||
await orm.em.flush();
|
||||
|
||||
return res.status(200).json({
|
||||
data: this.mapper.mapEntityToResponse(article),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<ArticleResponse>);
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response) {
|
||||
const parseParamsResult = articleParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const article = await orm.em.findOne(
|
||||
Article,
|
||||
{ slug: params.slug },
|
||||
{
|
||||
populate: ["*"],
|
||||
},
|
||||
);
|
||||
if (!article) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "slug",
|
||||
location: "params",
|
||||
message: "Article not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
await this.fileStorage.removeFile(article.thumbnail);
|
||||
|
||||
await orm.em.removeAndFlush(article);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
public buildRouter(): Router {
|
||||
const router = Router();
|
||||
router.post(
|
||||
"/",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.createArticle]),
|
||||
this.create.bind(this),
|
||||
);
|
||||
router.get("/", createOrmContextMiddleware, this.list.bind(this));
|
||||
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
|
||||
router.put(
|
||||
"/:id",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.updateArticle]),
|
||||
this.update.bind(this),
|
||||
);
|
||||
router.delete(
|
||||
"/:id",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.deleteArticle]),
|
||||
this.delete.bind(this),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
23
src/modules/article/article.mapper.ts
Normal file
23
src/modules/article/article.mapper.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Article } from "@/database/entities/article.entity";
|
||||
import type { ArticleResponse } from "@/modules/article/article.types";
|
||||
import type { TagMapper } from "@/modules/tag/tag.mapper";
|
||||
|
||||
export class ArticleMapper {
|
||||
public constructor(private readonly tagMapper: TagMapper) {}
|
||||
|
||||
public mapEntityToResponse(article: Article): ArticleResponse {
|
||||
return {
|
||||
id: article.id,
|
||||
thumbnail: article.thumbnail,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
tags: article.tags.map(
|
||||
this.tagMapper.mapEntityToResponse.bind(this.tagMapper),
|
||||
),
|
||||
content: article.content,
|
||||
views: article.views,
|
||||
created_at: article.createdAt,
|
||||
updated_at: article.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/modules/article/article.schemas.ts
Normal file
29
src/modules/article/article.schemas.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import z from "zod";
|
||||
|
||||
export const articleRequestSchema = z.object({
|
||||
thumbnail: z.base64("Must be base64 string.").nonempty("Must not empty."),
|
||||
title: z
|
||||
.string("Must be string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(100, "Max 100 characters."),
|
||||
tag_ids: z
|
||||
.array(
|
||||
z
|
||||
.string("Must be string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(200, "Max 200 characters."),
|
||||
"Must be array.",
|
||||
)
|
||||
.nonempty("Must not empty."),
|
||||
content: z
|
||||
.string("Must be string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(100, "Max 100 characters."),
|
||||
});
|
||||
|
||||
export const articleParamsSchema = z.object({
|
||||
slug: z
|
||||
.string("Must be string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(100, "Max 100 characters."),
|
||||
});
|
||||
22
src/modules/article/article.types.ts
Normal file
22
src/modules/article/article.types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {
|
||||
articleParamsSchema,
|
||||
articleRequestSchema,
|
||||
} from "@/modules/article/article.schemas";
|
||||
import type { TagResponse } from "@/modules/tag/tag.types";
|
||||
import z from "zod";
|
||||
|
||||
export type ArticleRequest = z.infer<typeof articleRequestSchema>;
|
||||
|
||||
export type ArticleParams = z.infer<typeof articleParamsSchema>;
|
||||
|
||||
export type ArticleResponse = {
|
||||
id: string;
|
||||
thumbnail: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
tags: TagResponse[];
|
||||
content: string;
|
||||
views: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
};
|
||||
@@ -82,7 +82,7 @@ export class PackageController extends Controller {
|
||||
|
||||
const package_ = orm.em.create(Package, {
|
||||
id: ulid(),
|
||||
slug: slugify(body.name),
|
||||
slug: slugify(body.name, { lower: true }),
|
||||
name: body.name,
|
||||
type: body.type,
|
||||
class: body.class,
|
||||
@@ -261,7 +261,7 @@ export class PackageController extends Controller {
|
||||
package_.thumbnail,
|
||||
);
|
||||
|
||||
package_.slug = slugify(body.name);
|
||||
package_.slug = slugify(body.name, { lower: true });
|
||||
package_.name = body.name;
|
||||
package_.type = body.type;
|
||||
package_.class = body.class;
|
||||
|
||||
206
src/modules/tag/tag.controller.ts
Normal file
206
src/modules/tag/tag.controller.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Controller } from "@/common/controller";
|
||||
import { createOrmContextMiddleware } from "@/common/middlewares/create-orm-context.middleware";
|
||||
import { isAdminMiddleware } from "@/common/middlewares/is-admin.middleware";
|
||||
import { paginationQuerySchema } from "@/common/schemas";
|
||||
import type { AbstractJwtService } from "@/common/services/jwt-service/abstract.jwt-service";
|
||||
import type {
|
||||
ErrorResponse,
|
||||
ListResponse,
|
||||
SingleResponse,
|
||||
} from "@/common/types";
|
||||
import { Tag } from "@/database/entities/tag.entity";
|
||||
import { AdminPermission } from "@/database/enums/admin-permission.enum";
|
||||
import { orm } from "@/database/orm";
|
||||
import type { TagMapper } from "@/modules/tag/tag.mapper";
|
||||
import { tagParamsSchema, tagRequestSchema } from "@/modules/tag/tag.schemas";
|
||||
import type { TagResponse } from "@/modules/tag/tag.types";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import slugify from "slugify";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export class TagController extends Controller {
|
||||
public constructor(
|
||||
private readonly mapper: TagMapper,
|
||||
private readonly jwtService: AbstractJwtService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response) {
|
||||
const parseBodyResult = tagRequestSchema.safeParse(req.body);
|
||||
if (!parseBodyResult.success) {
|
||||
return this.handleZodError(parseBodyResult.error, res, "body");
|
||||
}
|
||||
const body = parseBodyResult.data;
|
||||
|
||||
const tag = orm.em.create(Tag, {
|
||||
id: ulid(),
|
||||
slug: slugify(body.name, { lower: true }),
|
||||
name: body.name,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await orm.em.flush();
|
||||
|
||||
return res.status(201).json({
|
||||
data: this.mapper.mapEntityToResponse(tag),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<TagResponse>);
|
||||
}
|
||||
|
||||
async list(req: Request, res: Response) {
|
||||
const parseQueryResult = paginationQuerySchema.safeParse(req.query);
|
||||
if (!parseQueryResult.success) {
|
||||
return this.handleZodError(parseQueryResult.error, res, "query");
|
||||
}
|
||||
const query = parseQueryResult.data;
|
||||
|
||||
const count = await orm.em.count(Tag);
|
||||
|
||||
const countries = await orm.em.find(
|
||||
Tag,
|
||||
{},
|
||||
{
|
||||
limit: query.per_page,
|
||||
offset: (query.page - 1) * query.per_page,
|
||||
orderBy: { createdAt: "DESC" },
|
||||
},
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
data: countries.map(this.mapper.mapEntityToResponse.bind(this.mapper)),
|
||||
errors: null,
|
||||
meta: {
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
total_pages: Math.ceil(count / query.per_page),
|
||||
total_items: count,
|
||||
},
|
||||
} satisfies ListResponse<TagResponse>);
|
||||
}
|
||||
|
||||
async view(req: Request, res: Response) {
|
||||
const parseParamsResult = tagParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const tag = await orm.em.findOne(Tag, { id: params.id });
|
||||
if (!tag) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "id",
|
||||
location: "params",
|
||||
message: "Tag not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
data: this.mapper.mapEntityToResponse(tag),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<TagResponse>);
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response) {
|
||||
const parseParamsResult = tagParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const parseBodyResult = tagRequestSchema.safeParse(req.body);
|
||||
if (!parseBodyResult.success) {
|
||||
return this.handleZodError(parseBodyResult.error, res, "body");
|
||||
}
|
||||
const body = parseBodyResult.data;
|
||||
|
||||
const tag = await orm.em.findOne(Tag, { id: params.id });
|
||||
if (!tag) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "id",
|
||||
location: "params",
|
||||
message: "Tag not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
tag.slug = slugify(body.name, { lower: true });
|
||||
tag.name = body.name;
|
||||
tag.updatedAt = new Date();
|
||||
|
||||
await orm.em.flush();
|
||||
|
||||
return res.status(200).json({
|
||||
data: this.mapper.mapEntityToResponse(tag),
|
||||
errors: null,
|
||||
} satisfies SingleResponse<TagResponse>);
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response) {
|
||||
const parseParamsResult = tagParamsSchema.safeParse(req.params);
|
||||
if (!parseParamsResult.success) {
|
||||
return this.handleZodError(parseParamsResult.error, res, "params");
|
||||
}
|
||||
const params = parseParamsResult.data;
|
||||
|
||||
const tag = await orm.em.findOne(
|
||||
Tag,
|
||||
{ id: params.id },
|
||||
{
|
||||
populate: ["*"],
|
||||
},
|
||||
);
|
||||
if (!tag) {
|
||||
return res.status(404).json({
|
||||
data: null,
|
||||
errors: [
|
||||
{
|
||||
path: "id",
|
||||
location: "params",
|
||||
message: "Tag not found.",
|
||||
},
|
||||
],
|
||||
} satisfies ErrorResponse);
|
||||
}
|
||||
|
||||
await orm.em.removeAndFlush(tag);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
public buildRouter(): Router {
|
||||
const router = Router();
|
||||
router.post(
|
||||
"/",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.createTag]),
|
||||
this.create.bind(this),
|
||||
);
|
||||
router.get("/", createOrmContextMiddleware, this.list.bind(this));
|
||||
router.get("/:id", createOrmContextMiddleware, this.view.bind(this));
|
||||
router.put(
|
||||
"/:id",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.updateTag]),
|
||||
this.update.bind(this),
|
||||
);
|
||||
router.delete(
|
||||
"/:id",
|
||||
createOrmContextMiddleware,
|
||||
isAdminMiddleware(this.jwtService, [AdminPermission.deleteTag]),
|
||||
this.delete.bind(this),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
14
src/modules/tag/tag.mapper.ts
Normal file
14
src/modules/tag/tag.mapper.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Tag } from "@/database/entities/tag.entity";
|
||||
import type { TagResponse } from "@/modules/tag/tag.types";
|
||||
|
||||
export class TagMapper {
|
||||
public mapEntityToResponse(tag: Tag): TagResponse {
|
||||
return {
|
||||
id: tag.id,
|
||||
slug: tag.slug,
|
||||
name: tag.name,
|
||||
created_at: tag.createdAt,
|
||||
updated_at: tag.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
src/modules/tag/tag.schemas.ts
Normal file
15
src/modules/tag/tag.schemas.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
export const tagRequestSchema = z.object({
|
||||
name: z
|
||||
.string("Must be string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(100, "Max 100 characters."),
|
||||
});
|
||||
|
||||
export const tagParamsSchema = z.object({
|
||||
id: z
|
||||
.ulid("Must be ulid string.")
|
||||
.nonempty("Must not empty.")
|
||||
.max(30, "Max 30 characters."),
|
||||
});
|
||||
17
src/modules/tag/tag.types.ts
Normal file
17
src/modules/tag/tag.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
tagParamsSchema,
|
||||
tagRequestSchema,
|
||||
} from "@/modules/tag/tag.schemas";
|
||||
import z from "zod";
|
||||
|
||||
export type TagRequest = z.infer<typeof tagRequestSchema>;
|
||||
|
||||
export type TagParams = z.infer<typeof tagParamsSchema>;
|
||||
|
||||
export type TagResponse = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
};
|
||||
Reference in New Issue
Block a user