From e2960027f2fc1ea0e4637dff9252ee9201a312b0 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Fri, 17 Apr 2026 01:43:31 -0300 Subject: [PATCH] refactor: update article service methods to use external ID and improve caching --- src/app/(pages)/article/[slug]/page.tsx | 21 ++++++----- src/app/(pages)/home/page.tsx | 24 ++++++++++--- src/lib/feature/article/article.external.ts | 36 +++++++------------ src/lib/feature/article/article.model.ts | 1 - src/lib/feature/article/article.service.ts | 11 +++--- .../internal/update-article-form.tsx | 9 +++-- .../feature/article/article.service.test.ts | 14 ++++---- tests/lib/storage/storage.s3.test.ts | 23 +++++------- 8 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/app/(pages)/article/[slug]/page.tsx b/src/app/(pages)/article/[slug]/page.tsx index d5867dd..773f7f6 100644 --- a/src/app/(pages)/article/[slug]/page.tsx +++ b/src/app/(pages)/article/[slug]/page.tsx @@ -1,5 +1,6 @@ import { getArticleBySlug } from '@/lib/feature/article/article.external'; import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react'; +import { cacheTag } from 'next/cache'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { Suspense } from 'react'; @@ -10,10 +11,6 @@ type ArticlePageProps = { params: Promise<{ slug: string }>; }; -type ArticleContentProps = { - params: Promise<{ slug: string }>; -}; - function readingTime(text: string): number { const words = text.trim().split(/\s+/).length; return Math.max(1, Math.ceil(words / 200)); @@ -68,8 +65,11 @@ const ArticleContentSkeleton = () => ( ); -const ArticleContent = async ({ params }: ArticleContentProps) => { - const { slug } = await params; +const ArticleContent = async ({ slug }: { slug: string }) => { + 'use cache'; + + cacheTag(`article:slug:${slug}`); + const articleResult = await getArticleBySlug(slug); if (!articleResult.ok) throw articleResult.error; const article = articleResult.value; @@ -144,10 +144,15 @@ const ArticleContent = async ({ params }: ArticleContentProps) => { ); }; -const ArticlePage = ({ params }: ArticlePageProps) => { +const ArticleContentWrapper = async ({ params }: ArticlePageProps) => { + const { slug } = await params; + return ; +}; + +const ArticlePage = (props: ArticlePageProps) => { return ( }> - + ); }; diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index e0d6fb3..b101b94 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -1,15 +1,23 @@ import { ArticleList } from '@/ui/components/internal/article/article-list'; +import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton'; +import { Suspense } from 'react'; const DEFAULT_PAGE_SIZE = 4; type HomeProps = { - searchParams?: { page?: string; pageSize?: string }; + searchParams?: Promise<{ page?: string; pageSize?: string }>; }; -const Home = async ({ searchParams }: HomeProps) => { - const page = Number(searchParams?.page) || 0; - const pageSize = Number(searchParams?.pageSize) || DEFAULT_PAGE_SIZE; +const ArticleListWrapper = async ({ searchParams }: HomeProps) => { + const params = await searchParams; + const page = Number(params?.page) || 1; + const pageSize = Number(params?.pageSize) || DEFAULT_PAGE_SIZE; + + return ; +}; + +const Home = async (props: HomeProps) => { return (
@@ -20,7 +28,13 @@ const Home = async ({ searchParams }: HomeProps) => { Latest Articles
- + + } + > + +
); }; diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts index c6c98b0..2e5cea2 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -8,42 +8,34 @@ import { } from '@/lib/feature/article/article.model'; import * as service from '@/lib/feature/article/article.service'; import { getSessionData } from '@/lib/session/session-storage'; -import { TypedResult, wrap, wrapCached } from '@/utils/types/results'; +import { TypedResult, wrap } from '@/utils/types/results'; import { UUIDv4 } from '@/utils/types/uuid'; import { revalidateTag } from 'next/cache'; export const getArticleByExternalId: ( externalId: UUIDv4 -) => Promise> = wrapCached( +) => Promise> = wrap( async (externalId: UUIDv4): Promise => { const result = await service.getArticleByExternalId(externalId); if (!result.ok) throw result.error; return result.value; - }, - { - key: (id) => [`article:${id}`], - tags: (id) => ['articles', `article:${id}`], } ); export const getArticleBySlug: ( slug: string -) => Promise> = wrapCached( +) => Promise> = wrap( async (slug: string): Promise => { const result = await service.getArticleBySlug(slug); if (!result.ok) throw result.error; return result.value; - }, - { - key: (slug) => [`article:slug:${slug}`], - tags: (slug) => ['articles', `article:slug:${slug}`], } ); export const getArticlesPaginated: ( page?: number, pageSize?: number -) => Promise> = wrapCached( +) => Promise> = wrap( async ( page: number = 1, pageSize: number = 10 @@ -51,13 +43,6 @@ export const getArticlesPaginated: ( const result = await service.getArticlesPaginated(page, pageSize); if (!result.ok) throw result.error; return result.value; - }, - { - key: (page, pageSize) => [`articles:page:${page}:${pageSize}`], - tags: (page, pageSize) => [ - 'articles', - `articles:page:${page}-${pageSize}`, - ], } ); @@ -81,12 +66,12 @@ export const saveArticle: ( } ); -export const updateArticle: ( - articleId: string, +export const updateArticleByExternalId: ( + externalId: UUIDv4, article: UpdateArticleModel ) => Promise> = wrap( async ( - articleId: string, + externalId: UUIDv4, article: UpdateArticleModel ): Promise => { const session = await getSessionData(); @@ -96,11 +81,14 @@ export const updateArticle: ( ); } - const result = await service.updateArticle(articleId, article); + const result = await service.updateArticleByExternalId( + externalId, + article + ); if (!result.ok) throw result.error; revalidateTag('articles', 'max'); - revalidateTag(`article:${articleId}`, 'max'); + revalidateTag(`article:${externalId}`, 'max'); revalidateTag(`article:slug:${result.value.slug}`, 'max'); return result.value; diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts index 7851c97..5c5d1dc 100644 --- a/src/lib/feature/article/article.model.ts +++ b/src/lib/feature/article/article.model.ts @@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({ export type UpdateArticleModel = z.infer; export const ArticleModel = z.object({ - id: z.string(), title: z.string(), slug: z.string(), description: z.string(), diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 2121fce..9acc668 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -13,7 +13,6 @@ export const articleEntityToModel = ( articleEntity: ArticleEntity ): ArticleModel => { return { - id: articleEntity.id, title: articleEntity.title, slug: articleEntity.slug, description: articleEntity.description, @@ -123,21 +122,21 @@ export const saveArticle: ( ); /** Updates an existing article in the database. */ -export const updateArticle: ( - articleId: string, +export const updateArticleByExternalId: ( + externalId: string, article: UpdateArticleModel ) => Promise> = wrap( async ( - articleId: string, + externalId: string, article: UpdateArticleModel ): Promise => { const articleRepository = await getRepository(ArticleEntity); const existingArticle = await articleRepository.findOneBy({ - id: articleId, + externalId: externalId, }); if (!existingArticle) { - throw new Error(`Article with ID ${articleId} not found`); + throw new Error(`Article with ID ${externalId} not found`); } if (!!article.title) existingArticle.title = article.title; diff --git a/src/ui/components/internal/update-article-form.tsx b/src/ui/components/internal/update-article-form.tsx index 9d8ae78..33ff894 100644 --- a/src/ui/components/internal/update-article-form.tsx +++ b/src/ui/components/internal/update-article-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { updateArticle } from '@/lib/feature/article/article.external'; +import { updateArticleByExternalId } from '@/lib/feature/article/article.external'; import { ArticleModel } from '@/lib/feature/article/article.model'; import { uploadFile } from '@/lib/storage/storage.utils'; import { FileUploadField } from '@/ui/components/internal/file-upload-field'; @@ -97,7 +97,10 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => { const handleFormSubmit = useCallback( async (data: z.infer) => { - const result = await updateArticle(article.id, data); + const result = await updateArticleByExternalId( + article.externalId, + data + ); if (!result.ok) { toast.error('Failed to update article', { description: result.error.message, @@ -110,7 +113,7 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => { position: 'bottom-right', }); }, - [article.id] + [article.externalId] ); const handleCoverImageFileChange = useCallback( diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts index 11267cb..13aceb4 100644 --- a/tests/lib/feature/article/article.service.test.ts +++ b/tests/lib/feature/article/article.service.test.ts @@ -9,7 +9,7 @@ import { getArticlesByAuthorId, getArticlesPaginated, saveArticle, - updateArticle, + updateArticleByExternalId, } from '@/lib/feature/article/article.service'; import { CreateUserModel } from '@/lib/feature/user/user.model'; import { saveUser } from '@/lib/feature/user/user.service'; @@ -56,7 +56,6 @@ describe('ArticleService', () => { expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.value.id).toBeDefined(); expect(result.value.title).toBe(articleToSave.title); expect(result.value.slug).toBe(articleToSave.slug); expect(result.value.description).toBe(articleToSave.description); @@ -136,7 +135,6 @@ describe('ArticleService', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value).toBeDefined(); - expect(result.value?.id).toBe(article!.id); expect(result.value?.title).toBe(article!.title); expect(result.value?.slug).toBe(article!.slug); expect(result.value?.externalId).toBe(article!.externalId); @@ -219,12 +217,14 @@ describe('ArticleService', () => { const article = slugResult.value; expect(article).toBeDefined(); - const result = await updateArticle(article!.id, dataToUpdate); + const result = await updateArticleByExternalId( + article!.externalId, + dataToUpdate + ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value).toBeDefined(); - expect(result.value.id).toBe(article!.id); expect(result.value.title).toBe(dataToUpdate.title); expect(result.value.description).toBe(dataToUpdate.description); expect(result.value.slug).toBe(article!.slug); @@ -237,7 +237,7 @@ describe('ArticleService', () => { title: 'Updated Article Title', }; - const result = await updateArticle('9999', dataToUpdate); + const result = await updateArticleByExternalId('9999', dataToUpdate); expect(result.ok).toBe(false); if (result.ok) return; @@ -257,7 +257,7 @@ describe('ArticleService', () => { const saveResult = await saveArticle(articleToSave); expect(saveResult.ok).toBe(true); if (!saveResult.ok) return; - expect(saveResult.value.id).toBeDefined(); + expect(saveResult.value.externalId).toBeDefined(); const deleteResult = await deleteArticleByExternalId( saveResult.value.externalId diff --git a/tests/lib/storage/storage.s3.test.ts b/tests/lib/storage/storage.s3.test.ts index 12fe89d..08819d8 100644 --- a/tests/lib/storage/storage.s3.test.ts +++ b/tests/lib/storage/storage.s3.test.ts @@ -1,20 +1,15 @@ -import { S3StorageAdapter, S3StorageConfig } from '@/lib/storage/storage.adapter'; -import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + S3StorageAdapter, + S3StorageConfig, +} from '@/lib/storage/storage.adapter'; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import * as presigner from '@aws-sdk/s3-request-presigner'; import { mockClient } from 'aws-sdk-client-mock'; - - - - - - - - - - - - jest.mock('@aws-sdk/s3-request-presigner'); describe('S3StorageAdapter', () => {