feat: implement article management features with CRUD operations
Some checks failed
Build and Test / run-test (20.x) (push) Has been cancelled

This commit is contained in:
2026-04-09 21:49:29 -03:00
parent a338972c84
commit 878fc1094b
9 changed files with 609 additions and 0 deletions

View File

@@ -1 +1,2 @@
export { ArticleEntity } from '@/lib/feature/article/article.entity';
export { UserEntity } from '@/lib/feature/user/user.entity';

View File

@@ -0,0 +1,58 @@
import { UserEntity } from '@/lib/feature/user/user.entity';
import type { Relation } from '@/utils/types/relation';
import type { UUIDv4 } from '@/utils/types/uuid';
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
@Entity('articles')
export class ArticleEntity {
@PrimaryColumn({
type: 'bigint',
primary: true,
generated: true,
})
id: string;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'varchar', length: 255, unique: true })
slug: string;
@Column({ type: 'text' })
description: string;
@Column({ name: 'cover_image_url', type: 'text' })
coverImageUrl: string;
@Column({ type: 'text' })
content: string;
@Column({ name: 'author_id', type: 'bigint' })
authorId: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author: Relation<UserEntity>;
@Column({
name: 'created_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
createdAt: Date;
@Column({
name: 'updated_at',
type: 'timestamp with time zone',
default: () => 'now()',
})
updatedAt: Date;
@Column({
name: 'external_id',
type: 'uuid',
unique: true,
default: () => 'gen_random_uuid()',
})
externalId: UUIDv4;
}

View File

@@ -0,0 +1,61 @@
'use server';
import {
ArticleModel,
CreateArticleModel,
PaginatedArticlesResult,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import * as service from '@/lib/feature/article/article.service';
import { getSessionData } from '@/lib/session/session-storage';
import { UUIDv4 } from '@/utils/types/uuid';
export const getArticleByExternalId = async (
externalId: UUIDv4
): Promise<ArticleModel | null> => {
return await service.getArticleByExternalId(externalId);
};
export const getArticleBySlug = async (
slug: string
): Promise<ArticleModel | null> => {
return await service.getArticleBySlug(slug);
};
export const getArticlesPaginated = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedArticlesResult> => {
return await service.getArticlesPaginated(page, pageSize);
};
export const saveArticle = async (
article: CreateArticleModel
): Promise<ArticleModel> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can save articles.');
}
article.authorId = session.user.id;
return await service.saveArticle(article);
};
export const updateArticle = async (
articleId: string,
article: UpdateArticleModel
): Promise<ArticleModel> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can save articles.');
}
return await service.updateArticle(articleId, article);
};
export const deleteArticle = async (articleId: string): Promise<void> => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error('Unauthorized: Only admin users can delete articles.');
}
await service.deleteArticle(articleId);
};

View File

@@ -0,0 +1,45 @@
import { Pagination } from '@/utils/types/pagination';
import { z } from 'zod';
export const CreateArticleModel = z.object({
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
content: z.string(),
authorId: z.string().optional(),
});
export type CreateArticleModel = z.infer<typeof CreateArticleModel>;
export const UpdateArticleModel = z.object({
title: z.string().optional(),
slug: z.string().optional(),
description: z.string().optional(),
coverImageUrl: z.string().optional(),
content: z.string().optional(),
});
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
export const ArticleModel = z.object({
id: z.string(),
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
content: z.string(),
authorId: z.string(),
externalId: z.uuid(),
});
export type ArticleModel = z.infer<typeof ArticleModel>;
export const ArticleInfoModel = z.object({
externalId: z.uuid(),
title: z.string(),
slug: z.string(),
description: z.string(),
coverImageUrl: z.string(),
});
export type ArticleInfoModel = z.infer<typeof ArticleInfoModel>;
export const PaginatedArticlesResult = Pagination(ArticleModel);
export type PaginatedArticlesResult = z.infer<typeof PaginatedArticlesResult>;

View File

@@ -0,0 +1,177 @@
import { getRepository } from '@/lib/db/client';
import { ArticleEntity } from '@/lib/feature/article/article.entity';
import {
ArticleModel,
CreateArticleModel,
PaginatedArticlesResult,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import { UUIDv4 } from '@/utils/types/uuid';
export const articleEntityToModel = (
articleEntity: ArticleEntity
): ArticleModel => {
return {
id: articleEntity.id,
title: articleEntity.title,
slug: articleEntity.slug,
description: articleEntity.description,
coverImageUrl: articleEntity.coverImageUrl,
content: articleEntity.content,
authorId: articleEntity.authorId,
externalId: articleEntity.externalId,
};
};
/** Retrieves an artible by its external ID.
* @param externalId - The external ID of the article to retrieve.
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
* */
export const getArticleByExternalId = async (
externalId: UUIDv4
): Promise<ArticleModel | null> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntity = await articleRepository.findOneBy({
externalId: externalId,
});
if (!articleEntity) {
return null;
}
return articleEntityToModel(articleEntity);
};
/**
* Retrieves an article by its slug.
* @param slug - The slug of the article to retrieve.
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
*/
export const getArticleBySlug = async (
slug: string
): Promise<ArticleModel | null> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntity = await articleRepository.findOneBy({ slug });
if (!articleEntity) {
return null;
}
return articleEntityToModel(articleEntity);
};
/**
* Retrieves all articles by a given author ID.
* @param authorId - The ID of the author.
* @returns {Promise<ArticleModel[]>} A list of article models.
*/
export const getArticlesByAuthorId = async (
authorId: string
): Promise<ArticleModel[]> => {
const articleRepository = await getRepository(ArticleEntity);
const articleEntities = await articleRepository.findBy({ authorId });
return articleEntities.map(articleEntityToModel);
};
/**
* Retrieves a paginated list of articles ordered by creation date descending.
* @param page - The page number (1-based).
* @param pageSize - The number of articles per page.
* @returns {Promise<PaginatedArticlesResult>} The paginated result.
*/
export const getArticlesPaginated = async (
page: number = 1,
pageSize: number = 10
): Promise<PaginatedArticlesResult> => {
const articleRepository = await getRepository(ArticleEntity);
const [articleEntities, total] = await articleRepository.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data: articleEntities.map(articleEntityToModel),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
};
/**
* Saves a new article to the database.
* @param article - The article data to save.
* @returns {Promise<ArticleModel>} The saved article model.
* @throws {Error} If an article with the same slug already exists.
*/
export const saveArticle = async (
article: CreateArticleModel
): Promise<ArticleModel> => {
const articleRepository = await getRepository(ArticleEntity);
if (!article.authorId) {
throw new Error('Author ID is required to save an article');
}
if (!!(await articleRepository.findOneBy({ slug: article.slug }))) {
throw new Error(`Article with slug ${article.slug} already exists`);
}
const newArticle = articleRepository.create(article);
return articleEntityToModel(await articleRepository.save(newArticle));
};
/**
* Updates an existing article in the database.
* @param articleId - The ID of the article to update.
* @param article - The new article data.
* @returns {Promise<ArticleModel>} The updated article model.
* @throws {Error} If the article with the given ID does not exist.
*/
export const updateArticle = async (
articleId: string,
article: UpdateArticleModel
): Promise<ArticleModel> => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
id: articleId,
});
if (!existingArticle) {
throw new Error(`Article with ID ${articleId} not found`);
}
if (!!article.title) existingArticle.title = article.title;
if (!!article.slug) existingArticle.slug = article.slug;
if (!!article.description)
existingArticle.description = article.description;
if (!!article.coverImageUrl)
existingArticle.coverImageUrl = article.coverImageUrl;
if (!!article.content) existingArticle.content = article.content;
return articleEntityToModel(await articleRepository.save(existingArticle));
};
/**
* Deletes an article from the database.
* @param articleId - The ID of the article to delete.
* @throws {Error} If the article with the given ID does not exist.
*/
export const deleteArticle = async (articleId: string): Promise<void> => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
id: articleId,
});
if (!existingArticle) {
throw new Error(`Article with ID ${articleId} not found`);
}
await articleRepository.remove(existingArticle);
};