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;