diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml index 64072958..8a47847b 100644 --- a/.github/workflows/update-contributors.yml +++ b/.github/workflows/update-contributors.yml @@ -2,8 +2,7 @@ name: Update Contributors on: schedule: - # Run every hour - - cron: '0 * * * *' + - cron: '0 3 * * *' workflow_dispatch: # Allow manual trigger jobs: diff --git a/src/app/api/admin/import-prompts/route.ts b/src/app/api/admin/import-prompts/route.ts index 0d5ec5cc..b274a869 100644 --- a/src/app/api/admin/import-prompts/route.ts +++ b/src/app/api/admin/import-prompts/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { revalidatePrompts } from "@/lib/cache"; import fs from "fs/promises"; import path from "path"; @@ -246,6 +247,11 @@ export async function POST(request: NextRequest) { } } + // Revalidate prompts cache after import + if (imported > 0) { + revalidatePrompts(); + } + return NextResponse.json({ success: true, imported, @@ -291,6 +297,11 @@ export async function DELETE(request: NextRequest) { }, }); + // Revalidate prompts cache after deletion + if (result.count > 0) { + revalidatePrompts(); + } + return NextResponse.json({ success: true, deleted: result.count, diff --git a/src/app/api/admin/prompts/[id]/route.ts b/src/app/api/admin/prompts/[id]/route.ts index 2082c4f7..cd775d45 100644 --- a/src/app/api/admin/prompts/[id]/route.ts +++ b/src/app/api/admin/prompts/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { revalidatePrompts } from "@/lib/cache"; // DELETE - Hard delete a prompt (admin only) export async function DELETE( @@ -54,6 +55,9 @@ export async function DELETE( where: { id }, }); + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json({ success: true, message: "Prompt deleted successfully", diff --git a/src/app/api/prompts/[id]/feature/route.ts b/src/app/api/prompts/[id]/feature/route.ts index b4098bff..b88c222a 100644 --- a/src/app/api/prompts/[id]/feature/route.ts +++ b/src/app/api/prompts/[id]/feature/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { revalidatePrompts } from "@/lib/cache"; // POST /api/prompts/[id]/feature - Toggle featured status (admin only) export async function POST( @@ -45,6 +46,9 @@ export async function POST( }, }); + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json({ success: true, isFeatured: updatedPrompt.isFeatured, diff --git a/src/app/api/prompts/[id]/route.ts b/src/app/api/prompts/[id]/route.ts index 963afc87..5e7c02ff 100644 --- a/src/app/api/prompts/[id]/route.ts +++ b/src/app/api/prompts/[id]/route.ts @@ -5,6 +5,7 @@ import { db } from "@/lib/db"; import { generatePromptEmbedding } from "@/lib/ai/embeddings"; import { generatePromptSlug } from "@/lib/slug"; import { checkPromptQuality } from "@/lib/ai/quality-check"; +import { revalidatePrompts } from "@/lib/cache"; const updatePromptSchema = z.object({ title: z.string().min(1).max(200).optional(), @@ -233,12 +234,17 @@ export async function PATCH( }, }); console.log(`[Quality Check] Prompt ${id} delisted successfully`); + // Revalidate cache after auto-delist + revalidatePrompts(); } }).catch((err) => { console.error("[Quality Check] Failed to run quality check for prompt:", id, err); }); } + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json(prompt); } catch (error) { console.error("Update prompt error:", error); @@ -317,6 +323,9 @@ export async function DELETE( data: { deletedAt: new Date() }, }); + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json({ success: true, message: isOwner && isDelisted diff --git a/src/app/api/prompts/[id]/unlist/route.ts b/src/app/api/prompts/[id]/unlist/route.ts index 9edc4b50..a0cbec84 100644 --- a/src/app/api/prompts/[id]/unlist/route.ts +++ b/src/app/api/prompts/[id]/unlist/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { revalidatePrompts } from "@/lib/cache"; // Toggle unlist status (admin only) export async function POST( @@ -50,6 +51,9 @@ export async function POST( }, }); + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json({ success: true, isUnlisted: newUnlistedStatus, diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index a64ce284..51d43cd8 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -7,6 +7,7 @@ import { generatePromptEmbedding } from "@/lib/ai/embeddings"; import { generatePromptSlug } from "@/lib/slug"; import { checkPromptQuality } from "@/lib/ai/quality-check"; import { isSimilarContent, normalizeContent } from "@/lib/similarity"; +import { revalidatePrompts } from "@/lib/cache"; const promptSchema = z.object({ title: z.string().min(1).max(200), @@ -234,6 +235,8 @@ export async function POST(request: Request) { }, }); console.log(`[Quality Check] Prompt ${prompt.id} delisted successfully`); + // Revalidate cache after auto-delist + revalidatePrompts(); } }).catch((err) => { console.error("[Quality Check] Failed to run quality check for prompt:", prompt.id, err); @@ -242,6 +245,9 @@ export async function POST(request: Request) { console.log(`[Quality Check] Skipped - prompt ${prompt.id} is private`); } + // Revalidate prompts cache + revalidatePrompts(); + return NextResponse.json(prompt); } catch (error) { console.error("Create prompt error:", error); diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 31a9a014..b5829759 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from "next"; import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { Plus } from "lucide-react"; +import { unstable_cache } from "next/cache"; import { Button } from "@/components/ui/button"; import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list"; import { PromptFilters } from "@/components/prompts/prompt-filters"; @@ -11,6 +12,7 @@ import { McpServerPopup, McpIcon } from "@/components/mcp/mcp-server-popup"; import { db } from "@/lib/db"; import { isAISearchEnabled, semanticSearch } from "@/lib/ai/embeddings"; import { isAIGenerationEnabled } from "@/lib/ai/generation"; +import { CACHE_TAGS } from "@/lib/cache"; import config from "@/../prompts.config"; export const metadata: Metadata = { @@ -18,6 +20,100 @@ export const metadata: Metadata = { description: "Browse and discover AI prompts", }; +// Cached query for categories +const getCachedCategories = unstable_cache( + async () => { + return db.category.findMany({ + orderBy: [{ order: "asc" }, { name: "asc" }], + select: { + id: true, + name: true, + slug: true, + parentId: true, + }, + }); + }, + ["prompts-categories"], + { tags: [CACHE_TAGS.CATEGORIES, CACHE_TAGS.PROMPTS], revalidate: 3600 } +); + +// Cached query for tags +const getCachedTags = unstable_cache( + async () => { + return db.tag.findMany({ + orderBy: { name: "asc" }, + }); + }, + ["prompts-tags"], + { tags: [CACHE_TAGS.TAGS, CACHE_TAGS.PROMPTS], revalidate: 3600 } +); + +// Cached query for prompts list +const getCachedPrompts = unstable_cache( + async ( + where: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + orderBy: any, + perPage: number + ) => { + const [promptsRaw, totalCount] = await Promise.all([ + db.prompt.findMany({ + where, + orderBy, + skip: 0, + take: perPage, + include: { + author: { + select: { + id: true, + name: true, + username: true, + avatar: true, + }, + }, + category: { + include: { + parent: { + select: { id: true, name: true, slug: true }, + }, + }, + }, + tags: { + include: { + tag: true, + }, + }, + contributors: { + select: { + id: true, + username: true, + name: true, + avatar: true, + }, + }, + _count: { + select: { votes: true, contributors: true }, + }, + }, + }), + db.prompt.count({ where }), + ]); + + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prompts: promptsRaw.map((p: any) => ({ + ...p, + voteCount: p._count.votes, + contributorCount: p._count.contributors, + contributors: p.contributors, + })), + total: totalCount, + }; + }, + ["prompts-list"], + { tags: [CACHE_TAGS.PROMPTS_LIST, CACHE_TAGS.PROMPTS], revalidate: 60 } +); + interface PromptsPageProps { searchParams: Promise<{ q?: string; @@ -102,75 +198,17 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) { orderBy = { votes: { _count: "desc" } }; } - // Fetch initial prompts (first page) - const [promptsRaw, totalCount] = await Promise.all([ - db.prompt.findMany({ - where, - orderBy, - skip: 0, - take: perPage, - include: { - author: { - select: { - id: true, - name: true, - username: true, - avatar: true, - }, - }, - category: { - include: { - parent: { - select: { id: true, name: true, slug: true }, - }, - }, - }, - tags: { - include: { - tag: true, - }, - }, - contributors: { - select: { - id: true, - username: true, - name: true, - avatar: true, - }, - }, - _count: { - select: { votes: true, contributors: true }, - }, - }, - }), - db.prompt.count({ where }), - ]); - - // Transform to include voteCount and contributorCount - prompts = promptsRaw.map((p) => ({ - ...p, - voteCount: p._count.votes, - contributorCount: p._count.contributors, - contributors: p.contributors, - })); - total = totalCount; + // Fetch initial prompts (first page) - using cached query + const result = await getCachedPrompts(where, orderBy, perPage); + prompts = result.prompts; + total = result.total; } - // Fetch categories for filter (with parent info for nesting) - const categories = await db.category.findMany({ - orderBy: [{ order: "asc" }, { name: "asc" }], - select: { - id: true, - name: true, - slug: true, - parentId: true, - }, - }); - - // Fetch tags for filter - const tags = await db.tag.findMany({ - orderBy: { name: "asc" }, - }); + // Fetch categories and tags for filter - using cached queries + const [categories, tags] = await Promise.all([ + getCachedCategories(), + getCachedTags(), + ]); return (
diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 00000000..d959a900 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,29 @@ +import { unstable_cache } from "next/cache"; +import { revalidateTag } from "next/cache"; + +// Cache tags for prompts +export const CACHE_TAGS = { + PROMPTS: "prompts", + PROMPTS_LIST: "prompts-list", + CATEGORIES: "categories", + TAGS: "tags", +} as const; + +// Revalidate prompts cache - call this when prompts are created, updated, deleted, or unlisted +export function revalidatePrompts() { + revalidateTag(CACHE_TAGS.PROMPTS, "max"); + revalidateTag(CACHE_TAGS.PROMPTS_LIST, "max"); +} + +// Helper to create cached database queries with prompts tag +export function createCachedQuery( + queryFn: (...args: Args) => Promise, + keyParts: string[], + tags: string[] = [CACHE_TAGS.PROMPTS], + revalidateSeconds: number = 60 +) { + return unstable_cache(queryFn, keyParts, { + tags, + revalidate: revalidateSeconds, + }); +}