diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx index bb16a9a..e0d6fb3 100644 --- a/src/app/(pages)/home/page.tsx +++ b/src/app/(pages)/home/page.tsx @@ -1,14 +1,15 @@ import { ArticleList } from '@/ui/components/internal/article/article-list'; -import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton'; -import { Suspense } from 'react'; -const PAGE_SIZE = 4; +const DEFAULT_PAGE_SIZE = 4; type HomeProps = { - searchParams: Promise<{ page?: string; pageSize?: string }>; + searchParams?: { page?: string; pageSize?: string }; }; const Home = async ({ searchParams }: HomeProps) => { + const page = Number(searchParams?.page) || 0; + const pageSize = Number(searchParams?.pageSize) || DEFAULT_PAGE_SIZE; + return (
@@ -19,14 +20,7 @@ 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 df536d5..c6c98b0 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -8,43 +8,56 @@ 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 } from '@/utils/types/results'; +import { TypedResult, wrap, wrapCached } from '@/utils/types/results'; import { UUIDv4 } from '@/utils/types/uuid'; -import { revalidatePath } from 'next/cache'; +import { revalidateTag } from 'next/cache'; export const getArticleByExternalId: ( externalId: UUIDv4 -) => Promise> = wrap( +) => Promise> = wrapCached( 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> = wrap( +) => Promise> = wrapCached( 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> = wrap( +) => Promise> = wrapCached( async ( page: number = 1, pageSize: number = 10 ): Promise => { - // await new Promise((r) => setTimeout(r, 1000)); - 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}`, + ], } ); @@ -62,6 +75,8 @@ export const saveArticle: ( const result = await service.saveArticle(article); if (!result.ok) throw result.error; + + revalidateTag('articles', 'max'); return result.value; } ); @@ -83,13 +98,19 @@ export const updateArticle: ( const result = await service.updateArticle(articleId, article); if (!result.ok) throw result.error; - revalidatePath('/admin'); + + revalidateTag('articles', 'max'); + revalidateTag(`article:${articleId}`, 'max'); + revalidateTag(`article:slug:${result.value.slug}`, 'max'); + return result.value; } ); -export const deleteArticle: (articleId: string) => Promise> = - wrap(async (articleId: string): Promise => { +export const deleteArticleByExternalId: ( + externalId: UUIDv4 +) => Promise> = wrap( + async (externalId: UUIDv4): Promise => { const session = await getSessionData(); if (!session || !session?.user || session?.user.role !== 'admin') { throw new Error( @@ -97,7 +118,17 @@ export const deleteArticle: (articleId: string) => Promise> = ); } - const result = await service.deleteArticle(articleId); + const getResult = await service.getArticleByExternalId(externalId); + if (!getResult.ok) throw getResult.error; + const article = getResult.value; + + if (!article) throw new Error('Article not found'); + + const result = await service.deleteArticleByExternalId(externalId); if (!result.ok) throw result.error; - revalidatePath('/admin'); - }); + + revalidateTag('articles', 'max'); + revalidateTag(`article:${externalId}`, 'max'); + revalidateTag(`article:slug:${article.slug}`, 'max'); + } +); diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 6eaa5ae..2121fce 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -155,16 +155,19 @@ export const updateArticle: ( ); /** Deletes an article from the database. */ -export const deleteArticle: (articleId: string) => Promise> = - wrap(async (articleId: string): Promise => { +export const deleteArticleByExternalId: ( + externalId: UUIDv4 +) => Promise> = wrap( + async (externalId: UUIDv4): 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 ExternalID ${externalId} not found`); } await articleRepository.remove(existingArticle); - }); + } +); diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 26a93ce..6a6ad85 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -1,30 +1,14 @@ import { StorageProvider } from '@/lib/storage/storage.interface'; import { TypedResult, wrap } from '@/utils/types/results'; -import { DeleteObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { + DeleteObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { z } from 'zod'; - - - - - - - - - - - - - - - - - - - - - /** * Configuration for S3 storage adapter */ diff --git a/src/ui/components/internal/article/admin-article-card.tsx b/src/ui/components/internal/article/admin-article-card.tsx index 6497b75..52ab98e 100644 --- a/src/ui/components/internal/article/admin-article-card.tsx +++ b/src/ui/components/internal/article/admin-article-card.tsx @@ -62,8 +62,8 @@ export const AdminArticleCard = ({ article }: AdminArticleCardProps) => { diff --git a/src/ui/components/internal/article/admin-article-list.tsx b/src/ui/components/internal/article/admin-article-list.tsx index f7e6e52..4d0ba1b 100644 --- a/src/ui/components/internal/article/admin-article-list.tsx +++ b/src/ui/components/internal/article/admin-article-list.tsx @@ -144,8 +144,8 @@ export const AdminArticleList = async ({ diff --git a/src/ui/components/internal/article/article-list.tsx b/src/ui/components/internal/article/article-list.tsx index 248d596..303d25e 100644 --- a/src/ui/components/internal/article/article-list.tsx +++ b/src/ui/components/internal/article/article-list.tsx @@ -1,22 +1,18 @@ -// 'use client'; import { getArticlesPaginated } from '@/lib/feature/article/article.external'; import { ArticleCard } from '@/ui/components/internal/article-card'; import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination'; import { FileTextIcon } from 'lucide-react'; +import { cacheTag } from 'next/cache'; type ArticleListProps = { - searchParams: Promise<{ page?: string; pageSize?: string }>; - defaultPageSize: number; + page: number; + pageSize: number; }; -export const ArticleList = async ({ - searchParams, - defaultPageSize, -}: ArticleListProps) => { - const { page: pageParam, pageSize: pageSizeParam } = await searchParams; - const page = Math.max(1, Number(pageParam) || 1); - const pageSize = Number(pageSizeParam) || defaultPageSize; +export const ArticleList = async ({ page, pageSize }: ArticleListProps) => { + 'use cache'; + cacheTag('articles', `articles:page:${page}-${pageSize}`); const paginationResult = await getArticlesPaginated(page, pageSize); if (!paginationResult.ok) { diff --git a/src/ui/components/internal/article/delete-article-button.tsx b/src/ui/components/internal/article/delete-article-button.tsx index 1223808..cd74c32 100644 --- a/src/ui/components/internal/article/delete-article-button.tsx +++ b/src/ui/components/internal/article/delete-article-button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { deleteArticle } from '@/lib/feature/article/article.external'; +import { deleteArticleByExternalId } from '@/lib/feature/article/article.external'; import { AlertDialog, AlertDialogAction, @@ -13,24 +13,25 @@ import { AlertDialogTrigger, } from '@/ui/components/shadcn/alert-dialog'; import { Button } from '@/ui/components/shadcn/button'; +import { UUIDv4 } from '@/utils/types/uuid'; import { Trash2Icon } from 'lucide-react'; import { useTransition } from 'react'; import { toast } from 'sonner'; interface DeleteArticleButtonProps { - articleId: string; - articleTitle: string; + externalID: UUIDv4; + title: string; } export const DeleteArticleButton = ({ - articleId, - articleTitle, + externalID, + title, }: DeleteArticleButtonProps) => { const [isPending, startTransition] = useTransition(); const handleDelete = () => { startTransition(async () => { - const result = await deleteArticle(articleId); + const result = await deleteArticleByExternalId(externalID); if (!result.ok) { toast.error('Failed to delete article', { description: result.error.message, @@ -39,7 +40,7 @@ export const DeleteArticleButton = ({ return; } toast.success('Article deleted', { - description: `"${articleTitle}" has been removed.`, + description: `"${title}" has been removed.`, position: 'bottom-right', }); }); @@ -57,7 +58,7 @@ export const DeleteArticleButton = ({ Delete article? - This will permanently delete “{articleTitle} + This will permanently delete “{title} ”. This action cannot be undone. diff --git a/src/utils/types/results.ts b/src/utils/types/results.ts index c93eca8..61150c0 100644 --- a/src/utils/types/results.ts +++ b/src/utils/types/results.ts @@ -1,22 +1,8 @@ +import { unstable_cache } from 'next/cache'; + export type Result = { ok: true; value: T } | { ok: false; error: E }; export type TypedResult = Result; -export function wrapBlocking< - F extends (...args: never[]) => unknown, - E = unknown, ->( - fn: F, - mapError: (e: unknown) => E = (e) => e as E -): (...args: Parameters) => Result, E> { - return (...args) => { - try { - return { ok: true, value: fn(...args) as ReturnType }; - } catch (e) { - return { ok: false, error: mapError(e) }; - } - }; -} - export function wrap< F extends (...args: never[]) => Promise, E = unknown, @@ -35,3 +21,43 @@ export function wrap< } }; } + +export function wrapCached< + F extends (...args: never[]) => Promise, + E = unknown, +>( + fn: F, + options: { + key: (...args: Parameters) => string[]; + tags?: (...args: Parameters) => string[]; + revalidate?: number; + }, + mapError: (e: unknown) => E = (e) => e as E +): (...args: Parameters) => Promise>, E>> { + return async (...args: Parameters) => { + try { + const cachedFn = unstable_cache( + async (...innerArgs: Parameters) => { + return await fn(...innerArgs); + }, + options.key(...args), + { + tags: options.tags?.(...args), + revalidate: options.revalidate, + } + ); + + const value = await cachedFn(...args); + + return { + ok: true, + value: value as Awaited>, + }; + } catch (e) { + return { + ok: false, + error: mapError(e), + }; + } + }; +} diff --git a/src/utils/types/uuid.ts b/src/utils/types/uuid.ts index bfd9b9f..5838c7a 100644 --- a/src/utils/types/uuid.ts +++ b/src/utils/types/uuid.ts @@ -1,22 +1,4 @@ -type HexChar = - | '0' - | '1' - | '2' - | '3' - | '4' - | '5' - | '6' - | '7' - | '8' - | '9' - | 'a' - | 'b' - | 'c' - | 'd' - | 'e' - | 'f'; -type UUIDv4Segment = - `${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity +import { z } from 'zod'; -export type UUIDv4 = - `${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`; +export const UUIDv4 = z.uuid(); +export type UUIDv4 = z.infer; diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts index 8d5ea16..11267cb 100644 --- a/tests/lib/feature/article/article.service.test.ts +++ b/tests/lib/feature/article/article.service.test.ts @@ -1,13 +1,13 @@ -import { getArticlesPaginated } from '@/lib/feature/article/article.external'; import { CreateArticleModel, UpdateArticleModel, } from '@/lib/feature/article/article.model'; import { - deleteArticle, + deleteArticleByExternalId, getArticleByExternalId, getArticleBySlug, getArticlesByAuthorId, + getArticlesPaginated, saveArticle, updateArticle, } from '@/lib/feature/article/article.service'; @@ -259,7 +259,9 @@ describe('ArticleService', () => { if (!saveResult.ok) return; expect(saveResult.value.id).toBeDefined(); - const deleteResult = await deleteArticle(saveResult.value.id); + const deleteResult = await deleteArticleByExternalId( + saveResult.value.externalId + ); expect(deleteResult.ok).toBe(true); const getResult = await getArticleBySlug('article-to-delete'); @@ -269,7 +271,7 @@ describe('ArticleService', () => { }); test('cannot delete non-existing article', async () => { - const result = await deleteArticle('9999'); + const result = await deleteArticleByExternalId('9999'); expect(result.ok).toBe(false); if (result.ok) return;