add news api

This commit is contained in:
ItsMalma
2025-12-01 18:56:29 +07:00
parent 393b65830c
commit 39661e8723
13 changed files with 699 additions and 2 deletions

View 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;
}
}

View 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,
};
}
}

View 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."),
});

View 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;
};

View File

@@ -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;

View 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;
}
}

View 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,
};
}
}

View 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."),
});

View 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;
};