mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 15:52:47 +00:00
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { revalidateTag } from "next/cache";
|
|
import { z } from "zod";
|
|
import { auth } from "@/lib/auth";
|
|
import { db } from "@/lib/db";
|
|
import { triggerWebhooks } from "@/lib/webhook";
|
|
import { generatePromptEmbedding, findAndSaveRelatedPrompts } from "@/lib/ai/embeddings";
|
|
import { generatePromptSlug } from "@/lib/slug";
|
|
import { checkPromptQuality } from "@/lib/ai/quality-check";
|
|
import { isSimilarContent, normalizeContent } from "@/lib/similarity";
|
|
|
|
const promptSchema = z.object({
|
|
title: z.string().min(1).max(200),
|
|
description: z.string().max(500).optional(),
|
|
content: z.string().min(1),
|
|
type: z.enum(["TEXT", "IMAGE", "VIDEO", "AUDIO", "SKILL"]), // Output type or SKILL
|
|
structuredFormat: z.enum(["JSON", "YAML"]).nullish(), // Input type indicator
|
|
categoryId: z.string().optional(),
|
|
tagIds: z.array(z.string()),
|
|
contributorIds: z.array(z.string()).optional(),
|
|
isPrivate: z.boolean(),
|
|
mediaUrl: z.string().url().optional().or(z.literal("")),
|
|
requiresMediaUpload: z.boolean().optional(),
|
|
requiredMediaType: z.enum(["IMAGE", "VIDEO", "DOCUMENT"]).optional(),
|
|
requiredMediaCount: z.number().int().min(1).max(10).optional(),
|
|
bestWithModels: z.array(z.string()).max(3).optional(),
|
|
bestWithMCP: z.array(z.object({
|
|
command: z.string(),
|
|
tools: z.array(z.string()).optional(),
|
|
})).optional(),
|
|
workflowLink: z.string().url().optional().or(z.literal("")),
|
|
});
|
|
|
|
// Create prompt
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user) {
|
|
return NextResponse.json(
|
|
{ error: "unauthorized", message: "You must be logged in" },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
const parsed = promptSchema.safeParse(body);
|
|
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "validation_error", message: "Invalid input", details: parsed.error.issues },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount, bestWithModels, bestWithMCP, workflowLink } = parsed.data;
|
|
|
|
// Check if user is flagged (for auto-delisting and daily limit)
|
|
const currentUser = await db.user.findUnique({
|
|
where: { id: session.user.id },
|
|
select: { flagged: true },
|
|
});
|
|
const isUserFlagged = currentUser?.flagged ?? false;
|
|
|
|
// Daily limit for flagged users: 5 prompts per day
|
|
if (isUserFlagged) {
|
|
const startOfDay = new Date();
|
|
startOfDay.setHours(0, 0, 0, 0);
|
|
|
|
const todayPromptCount = await db.prompt.count({
|
|
where: {
|
|
authorId: session.user.id,
|
|
createdAt: { gte: startOfDay },
|
|
},
|
|
});
|
|
|
|
if (todayPromptCount >= 5) {
|
|
return NextResponse.json(
|
|
{ error: "daily_limit", message: "You have reached the daily limit of 5 prompts" },
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rate limit: Check if user created a prompt in the last 30 seconds
|
|
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
|
|
const recentPrompt = await db.prompt.findFirst({
|
|
where: {
|
|
authorId: session.user.id,
|
|
createdAt: { gte: thirtySecondsAgo },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
if (recentPrompt) {
|
|
return NextResponse.json(
|
|
{ error: "rate_limit", message: "Please wait 30 seconds before creating another prompt" },
|
|
{ status: 429 }
|
|
);
|
|
}
|
|
|
|
// Check for duplicate title or content from the same user
|
|
const userDuplicate = await db.prompt.findFirst({
|
|
where: {
|
|
authorId: session.user.id,
|
|
deletedAt: null,
|
|
OR: [
|
|
{ title: { equals: title, mode: "insensitive" } },
|
|
{ content: content },
|
|
],
|
|
},
|
|
select: { id: true, slug: true, title: true },
|
|
});
|
|
|
|
if (userDuplicate) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "duplicate_prompt",
|
|
message: "You already have a prompt with the same title or content",
|
|
existingPromptId: userDuplicate.id,
|
|
existingPromptSlug: userDuplicate.slug,
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
|
|
// Check for similar content system-wide (any user)
|
|
// First, get a batch of public prompts to check similarity against
|
|
const normalizedNewContent = normalizeContent(content);
|
|
|
|
// Only check if normalized content has meaningful length
|
|
if (normalizedNewContent.length > 50) {
|
|
// Get recent public prompts to check for similarity (limit to avoid performance issues)
|
|
const publicPrompts = await db.prompt.findMany({
|
|
where: {
|
|
deletedAt: null,
|
|
isPrivate: false,
|
|
},
|
|
select: {
|
|
id: true,
|
|
slug: true,
|
|
title: true,
|
|
content: true,
|
|
author: { select: { username: true } }
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 1000, // Check against last 1000 public prompts
|
|
});
|
|
|
|
// Find similar content using our similarity algorithm
|
|
const similarPrompt = publicPrompts.find(p => isSimilarContent(content, p.content));
|
|
|
|
if (similarPrompt) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "content_exists",
|
|
message: "A prompt with similar content already exists",
|
|
existingPromptId: similarPrompt.id,
|
|
existingPromptSlug: similarPrompt.slug,
|
|
existingPromptTitle: similarPrompt.title,
|
|
existingPromptAuthor: similarPrompt.author.username,
|
|
},
|
|
{ status: 409 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Generate slug from title (translated to English)
|
|
const slug = await generatePromptSlug(title);
|
|
|
|
// Create prompt with tags
|
|
// Auto-delist if user is flagged
|
|
const prompt = await db.prompt.create({
|
|
data: {
|
|
title,
|
|
slug,
|
|
description: description || null,
|
|
content,
|
|
type,
|
|
structuredFormat: structuredFormat || null,
|
|
isPrivate,
|
|
mediaUrl: mediaUrl || null,
|
|
requiresMediaUpload: requiresMediaUpload || false,
|
|
requiredMediaType: requiresMediaUpload ? requiredMediaType : null,
|
|
requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null,
|
|
bestWithModels: bestWithModels || [],
|
|
bestWithMCP: bestWithMCP || [],
|
|
workflowLink: workflowLink || null,
|
|
authorId: session.user.id,
|
|
categoryId: categoryId || null,
|
|
// Auto-delist prompts from flagged users
|
|
...(isUserFlagged && {
|
|
isUnlisted: true,
|
|
unlistedAt: new Date(),
|
|
delistReason: "UNUSUAL_ACTIVITY",
|
|
}),
|
|
tags: {
|
|
create: tagIds.map((tagId) => ({
|
|
tagId,
|
|
})),
|
|
},
|
|
...(contributorIds && contributorIds.length > 0 && {
|
|
contributors: {
|
|
connect: contributorIds.map((id) => ({ id })),
|
|
},
|
|
}),
|
|
},
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
username: true,
|
|
avatar: true,
|
|
verified: true,
|
|
},
|
|
},
|
|
category: {
|
|
include: {
|
|
parent: true,
|
|
},
|
|
},
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Create initial version
|
|
await db.promptVersion.create({
|
|
data: {
|
|
promptId: prompt.id,
|
|
version: 1,
|
|
content,
|
|
changeNote: "Initial version",
|
|
createdBy: session.user.id,
|
|
},
|
|
});
|
|
|
|
// Trigger webhooks for new prompt (non-blocking)
|
|
if (!isPrivate) {
|
|
triggerWebhooks("PROMPT_CREATED", {
|
|
id: prompt.id,
|
|
title: prompt.title,
|
|
description: prompt.description,
|
|
content: prompt.content,
|
|
type: prompt.type,
|
|
mediaUrl: prompt.mediaUrl,
|
|
isPrivate: prompt.isPrivate,
|
|
author: prompt.author,
|
|
category: prompt.category,
|
|
tags: prompt.tags,
|
|
});
|
|
}
|
|
|
|
// Generate embedding for AI search (non-blocking)
|
|
// Only for public prompts - the function checks if aiSearch is enabled
|
|
// After embedding is generated, find and save related prompts
|
|
if (!isPrivate) {
|
|
generatePromptEmbedding(prompt.id)
|
|
.then(() => findAndSaveRelatedPrompts(prompt.id))
|
|
.catch((err) =>
|
|
console.error("Failed to generate embedding/related prompts for:", prompt.id, err)
|
|
);
|
|
}
|
|
|
|
// Run quality check for auto-delist (non-blocking for public prompts)
|
|
// This runs in the background and will delist the prompt if quality issues are found
|
|
if (!isPrivate) {
|
|
console.log(`[Quality Check] Starting check for prompt ${prompt.id}`);
|
|
checkPromptQuality(title, content, description).then(async (result) => {
|
|
console.log(`[Quality Check] Result for prompt ${prompt.id}:`, JSON.stringify(result));
|
|
if (result.shouldDelist && result.reason) {
|
|
console.log(`[Quality Check] Auto-delisting prompt ${prompt.id}: ${result.reason} - ${result.details}`);
|
|
await db.prompt.update({
|
|
where: { id: prompt.id },
|
|
data: {
|
|
isUnlisted: true,
|
|
unlistedAt: new Date(),
|
|
delistReason: result.reason,
|
|
},
|
|
});
|
|
console.log(`[Quality Check] Prompt ${prompt.id} delisted successfully`);
|
|
}
|
|
}).catch((err) => {
|
|
console.error("[Quality Check] Failed to run quality check for prompt:", prompt.id, err);
|
|
});
|
|
} else {
|
|
console.log(`[Quality Check] Skipped - prompt ${prompt.id} is private`);
|
|
}
|
|
|
|
// Revalidate caches (prompts, categories, tags counts change)
|
|
revalidateTag("prompts", "max");
|
|
revalidateTag("categories", "max");
|
|
revalidateTag("tags", "max");
|
|
|
|
return NextResponse.json(prompt);
|
|
} catch (error) {
|
|
console.error("Create prompt error:", error);
|
|
return NextResponse.json(
|
|
{ error: "server_error", message: "Something went wrong" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// List prompts (for API access)
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const { searchParams } = new URL(request.url);
|
|
const page = parseInt(searchParams.get("page") || "1");
|
|
const perPage = parseInt(searchParams.get("perPage") || "24");
|
|
const type = searchParams.get("type");
|
|
const categoryId = searchParams.get("category");
|
|
const tag = searchParams.get("tag");
|
|
const sort = searchParams.get("sort");
|
|
const q = searchParams.get("q");
|
|
|
|
const where: Record<string, unknown> = {
|
|
isPrivate: false,
|
|
isUnlisted: false, // Exclude unlisted prompts from public API
|
|
deletedAt: null, // Exclude soft-deleted prompts
|
|
// Exclude intermediate flow prompts (only show first prompts or standalone)
|
|
// Note: "related" connections are AI-suggested similar prompts, not flow connections
|
|
incomingConnections: { none: { label: { not: "related" } } },
|
|
};
|
|
|
|
if (type) {
|
|
where.type = type;
|
|
}
|
|
|
|
if (categoryId) {
|
|
where.categoryId = categoryId;
|
|
}
|
|
|
|
if (tag) {
|
|
where.tags = {
|
|
some: {
|
|
tag: { slug: tag },
|
|
},
|
|
};
|
|
}
|
|
|
|
if (q) {
|
|
where.OR = [
|
|
{ title: { contains: q, mode: "insensitive" } },
|
|
{ content: { contains: q, mode: "insensitive" } },
|
|
{ description: { contains: q, mode: "insensitive" } },
|
|
];
|
|
}
|
|
|
|
// Build order by clause
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let orderBy: any = { createdAt: "desc" };
|
|
if (sort === "oldest") {
|
|
orderBy = { createdAt: "asc" };
|
|
} else if (sort === "upvotes") {
|
|
orderBy = { votes: { _count: "desc" } };
|
|
}
|
|
|
|
const [promptsRaw, total] = await Promise.all([
|
|
db.prompt.findMany({
|
|
where,
|
|
orderBy,
|
|
skip: (page - 1) * perPage,
|
|
take: perPage,
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
username: true,
|
|
avatar: true,
|
|
verified: 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,
|
|
outgoingConnections: { where: { label: { not: "related" } } },
|
|
incomingConnections: { where: { label: { not: "related" } } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
db.prompt.count({ where }),
|
|
]);
|
|
|
|
// Transform to include voteCount and contributorCount, exclude internal fields
|
|
const prompts = promptsRaw.map(({ embedding: _e, isPrivate: _p, isUnlisted: _u, unlistedAt: _ua, deletedAt: _d, ...p }) => ({
|
|
...p,
|
|
voteCount: p._count.votes,
|
|
contributorCount: p._count.contributors,
|
|
contributors: p.contributors,
|
|
}));
|
|
|
|
return NextResponse.json({
|
|
prompts,
|
|
total,
|
|
page,
|
|
perPage,
|
|
totalPages: Math.ceil(total / perPage),
|
|
});
|
|
} catch (error) {
|
|
console.error("List prompts error:", error);
|
|
return NextResponse.json(
|
|
{ error: "server_error", message: "Something went wrong" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|