add news api
This commit is contained in:
@@ -15,6 +15,8 @@ import { AirlineController } from "@/modules/airline/airline.controller";
|
|||||||
import { AirlineMapper } from "@/modules/airline/airline.mapper";
|
import { AirlineMapper } from "@/modules/airline/airline.mapper";
|
||||||
import { AirportController } from "@/modules/airport/airport.controller";
|
import { AirportController } from "@/modules/airport/airport.controller";
|
||||||
import { AirportMapper } from "@/modules/airport/airport.mapper";
|
import { AirportMapper } from "@/modules/airport/airport.mapper";
|
||||||
|
import { ArticleController } from "@/modules/article/article.controller";
|
||||||
|
import { ArticleMapper } from "@/modules/article/article.mapper";
|
||||||
import { CityController } from "@/modules/city/city.controller";
|
import { CityController } from "@/modules/city/city.controller";
|
||||||
import { CityMapper } from "@/modules/city/city.mapper";
|
import { CityMapper } from "@/modules/city/city.mapper";
|
||||||
import { CountryController } from "@/modules/country/country.controller";
|
import { CountryController } from "@/modules/country/country.controller";
|
||||||
@@ -34,6 +36,8 @@ import { PackageMapper } from "@/modules/package/package.mapper";
|
|||||||
import { PartnerController } from "@/modules/partner/partner.controller";
|
import { PartnerController } from "@/modules/partner/partner.controller";
|
||||||
import { PartnerMapper } from "@/modules/partner/partner.mapper";
|
import { PartnerMapper } from "@/modules/partner/partner.mapper";
|
||||||
import { StaticController } from "@/modules/static/static.controller";
|
import { StaticController } from "@/modules/static/static.controller";
|
||||||
|
import { TagController } from "@/modules/tag/tag.controller";
|
||||||
|
import { TagMapper } from "@/modules/tag/tag.mapper";
|
||||||
import { TransportationClassController } from "@/modules/transportation-class/transportation-class.controller";
|
import { TransportationClassController } from "@/modules/transportation-class/transportation-class.controller";
|
||||||
import { TransportationClassMapper } from "@/modules/transportation-class/transportation-class.mapper";
|
import { TransportationClassMapper } from "@/modules/transportation-class/transportation-class.mapper";
|
||||||
import { TransportationController } from "@/modules/transportation/transportation.controller";
|
import { TransportationController } from "@/modules/transportation/transportation.controller";
|
||||||
@@ -96,6 +100,8 @@ export class Application {
|
|||||||
);
|
);
|
||||||
const adminMapper = new AdminMapper();
|
const adminMapper = new AdminMapper();
|
||||||
const orderMapper = new OrderMapper(packageMapper, partnerMapper);
|
const orderMapper = new OrderMapper(packageMapper, partnerMapper);
|
||||||
|
const tagMapper = new TagMapper();
|
||||||
|
const articleMapper = new ArticleMapper(tagMapper);
|
||||||
|
|
||||||
const countryRouter = new CountryController(
|
const countryRouter = new CountryController(
|
||||||
countryMapper,
|
countryMapper,
|
||||||
@@ -164,6 +170,15 @@ export class Application {
|
|||||||
const whatsAppRouter = new WhatsAppController(
|
const whatsAppRouter = new WhatsAppController(
|
||||||
this._whatsAppService,
|
this._whatsAppService,
|
||||||
).buildRouter();
|
).buildRouter();
|
||||||
|
const tagRouter = new TagController(
|
||||||
|
tagMapper,
|
||||||
|
this._jwtService,
|
||||||
|
).buildRouter();
|
||||||
|
const articleRouter = new ArticleController(
|
||||||
|
articleMapper,
|
||||||
|
this._fileStorage,
|
||||||
|
this._jwtService,
|
||||||
|
).buildRouter();
|
||||||
|
|
||||||
this._app.use("/countries", countryRouter);
|
this._app.use("/countries", countryRouter);
|
||||||
this._app.use("/cities", cityRouter);
|
this._app.use("/cities", cityRouter);
|
||||||
@@ -181,6 +196,8 @@ export class Application {
|
|||||||
this._app.use("/orders", orderRouter);
|
this._app.use("/orders", orderRouter);
|
||||||
this._app.use("/statics", staticRouter);
|
this._app.use("/statics", staticRouter);
|
||||||
this._app.use("/whatsapp", whatsAppRouter);
|
this._app.use("/whatsapp", whatsAppRouter);
|
||||||
|
this._app.use("/tags", tagRouter);
|
||||||
|
this._app.use("/articles", articleRouter);
|
||||||
}
|
}
|
||||||
|
|
||||||
public initializeErrorHandlers() {}
|
public initializeErrorHandlers() {}
|
||||||
|
|||||||
47
src/database/entities/article.entity.ts
Normal file
47
src/database/entities/article.entity.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Tag } from "@/database/entities/tag.entity";
|
||||||
|
import {
|
||||||
|
Collection,
|
||||||
|
Entity,
|
||||||
|
ManyToMany,
|
||||||
|
PrimaryKey,
|
||||||
|
Property,
|
||||||
|
Unique,
|
||||||
|
} from "@mikro-orm/core";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Article {
|
||||||
|
@PrimaryKey({ type: "varchar", length: 30 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 100 })
|
||||||
|
thumbnail!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 100 })
|
||||||
|
@Unique()
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 100 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ManyToMany(() => Tag)
|
||||||
|
tags = new Collection<Tag>(this);
|
||||||
|
|
||||||
|
@Property({ type: "text" })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Property({ type: "integer" })
|
||||||
|
views!: number;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "timestamp",
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "timestamp",
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
})
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
27
src/database/entities/tag.entity.ts
Normal file
27
src/database/entities/tag.entity.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Entity, PrimaryKey, Property, Unique } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Tag {
|
||||||
|
@PrimaryKey({ type: "varchar", length: 30 })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 100 })
|
||||||
|
@Unique()
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 100 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "timestamp",
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
})
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
type: "timestamp",
|
||||||
|
onCreate: () => new Date(),
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
})
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
@@ -55,4 +55,12 @@ export enum AdminPermission {
|
|||||||
createPartner = "partner:create",
|
createPartner = "partner:create",
|
||||||
updatePartner = "partner:update",
|
updatePartner = "partner:update",
|
||||||
deletePartner = "partner:delete",
|
deletePartner = "partner:delete",
|
||||||
|
// Tag permissions
|
||||||
|
createTag = "tag:create",
|
||||||
|
updateTag = "tag:update",
|
||||||
|
deleteTag = "tag:delete",
|
||||||
|
// Article permissions
|
||||||
|
createArticle = "article:create",
|
||||||
|
updateArticle = "article:update",
|
||||||
|
deleteArticle = "article:delete",
|
||||||
}
|
}
|
||||||
|
|||||||
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, {
|
const package_ = orm.em.create(Package, {
|
||||||
id: ulid(),
|
id: ulid(),
|
||||||
slug: slugify(body.name),
|
slug: slugify(body.name, { lower: true }),
|
||||||
name: body.name,
|
name: body.name,
|
||||||
type: body.type,
|
type: body.type,
|
||||||
class: body.class,
|
class: body.class,
|
||||||
@@ -261,7 +261,7 @@ export class PackageController extends Controller {
|
|||||||
package_.thumbnail,
|
package_.thumbnail,
|
||||||
);
|
);
|
||||||
|
|
||||||
package_.slug = slugify(body.name);
|
package_.slug = slugify(body.name, { lower: true });
|
||||||
package_.name = body.name;
|
package_.name = body.name;
|
||||||
package_.type = body.type;
|
package_.type = body.type;
|
||||||
package_.class = body.class;
|
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