refactor: update article service methods to use external ID and improve caching
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 2m5s
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 2m5s
This commit is contained in:
@@ -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 = () => (
|
||||
</article>
|
||||
);
|
||||
|
||||
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 <ArticleContent slug={slug} />;
|
||||
};
|
||||
|
||||
const ArticlePage = (props: ArticlePageProps) => {
|
||||
return (
|
||||
<Suspense fallback={<ArticleContentSkeleton />}>
|
||||
<ArticleContent params={params} />
|
||||
<ArticleContentWrapper {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 <ArticleList page={page} pageSize={pageSize} />;
|
||||
};
|
||||
|
||||
const Home = async (props: HomeProps) => {
|
||||
return (
|
||||
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
|
||||
<div className='mb-10 border-b border-border pb-8'>
|
||||
@@ -20,7 +28,13 @@ const Home = async ({ searchParams }: HomeProps) => {
|
||||
Latest Articles
|
||||
</h1>
|
||||
</div>
|
||||
<ArticleList page={page} pageSize={pageSize} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<ArticleListSkeleton skeletonSize={DEFAULT_PAGE_SIZE} />
|
||||
}
|
||||
>
|
||||
<ArticleListWrapper {...props} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<TypedResult<ArticleModel | null>> = wrapCached(
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (externalId: UUIDv4): Promise<ArticleModel | null> => {
|
||||
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<TypedResult<ArticleModel | null>> = wrapCached(
|
||||
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||
async (slug: string): Promise<ArticleModel | null> => {
|
||||
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<TypedResult<PaginatedArticlesResult>> = wrapCached(
|
||||
) => Promise<TypedResult<PaginatedArticlesResult>> = 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<TypedResult<ArticleModel>> = wrap(
|
||||
async (
|
||||
articleId: string,
|
||||
externalId: UUIDv4,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
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;
|
||||
|
||||
@@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({
|
||||
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
|
||||
|
||||
export const ArticleModel = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string(),
|
||||
|
||||
@@ -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<TypedResult<ArticleModel>> = wrap(
|
||||
async (
|
||||
articleId: string,
|
||||
externalId: string,
|
||||
article: UpdateArticleModel
|
||||
): Promise<ArticleModel> => {
|
||||
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;
|
||||
|
||||
@@ -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<typeof formSchema>) => {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user