Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 71 additions & 26 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^10.32.1",
"@tailwindcss/typography": "^0.5.19",
"@types/d3": "^7.4.3",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3": "^7.9.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
"jszip": "^3.10.1",
"lucide-react": "^0.556.0",
"next": "^16.0.10",
Expand Down Expand Up @@ -94,7 +94,12 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/d3": "^7.4.3",
"@types/mdast": "^4.0.4",
"@types/mdx": "^2.0.13",
"@types/mysql": "^2.15.27",
"@types/node": "^20.19.33",
"@types/pg": "^8.16.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
34 changes: 19 additions & 15 deletions src/app/api/prompts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generatePromptEmbedding, findAndSaveRelatedPrompts } from "@/lib/ai/emb
import { generatePromptSlug } from "@/lib/slug";
import { checkPromptQuality } from "@/lib/ai/quality-check";
import { isSimilarContent, normalizeContent } from "@/lib/similarity";
import { logger } from "@/lib/logger";

const promptSchema = z.object({
title: z.string().min(1).max(200),
Expand Down Expand Up @@ -147,7 +148,7 @@ export async function POST(request: Request) {
});

// Find similar content using our similarity algorithm
const similarPrompt = publicPrompts.find(p => isSimilarContent(content, p.content));
const similarPrompt = publicPrompts.find((p: { id: string; slug: string | null; title: string; content: string; author: { username: string } }) => isSimilarContent(content, p.content));

if (similarPrompt) {
return NextResponse.json(
Expand Down Expand Up @@ -261,18 +262,18 @@ export async function POST(request: Request) {
generatePromptEmbedding(prompt.id)
.then(() => findAndSaveRelatedPrompts(prompt.id))
.catch((err) =>
console.error("Failed to generate embedding/related prompts for:", prompt.id, err)
logger.error({ error: err, promptId: prompt.id }, "Failed to generate embedding/related prompts")
);
}

// 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}`);
logger.info({ promptId: prompt.id }, "[Quality Check] Starting check");
checkPromptQuality(title, content, description).then(async (result) => {
console.log(`[Quality Check] Result for prompt ${prompt.id}:`, JSON.stringify(result));
logger.info({ promptId: prompt.id, result }, "[Quality Check] Result");
if (result.shouldDelist && result.reason) {
console.log(`[Quality Check] Auto-delisting prompt ${prompt.id}: ${result.reason} - ${result.details}`);
logger.info({ promptId: prompt.id, reason: result.reason, details: result.details }, "[Quality Check] Auto-delisting prompt");
await db.prompt.update({
where: { id: prompt.id },
data: {
Expand All @@ -281,13 +282,13 @@ export async function POST(request: Request) {
delistReason: result.reason,
},
});
console.log(`[Quality Check] Prompt ${prompt.id} delisted successfully`);
logger.info({ promptId: prompt.id }, "[Quality Check] Prompt delisted successfully");
}
}).catch((err) => {
console.error("[Quality Check] Failed to run quality check for prompt:", prompt.id, err);
logger.error({ error: err, promptId: prompt.id }, "[Quality Check] Failed to run quality check");
});
} else {
console.log(`[Quality Check] Skipped - prompt ${prompt.id} is private`);
logger.debug({ promptId: prompt.id }, "[Quality Check] Skipped - prompt is private");
}

// Revalidate caches (prompts, categories, tags counts change)
Expand Down Expand Up @@ -430,12 +431,15 @@ export async function GET(request: Request) {
]);

// 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,
}));
const prompts = promptsRaw.map((promptRaw) => {
const { isPrivate, isUnlisted, unlistedAt, deletedAt, ...rest } = promptRaw as typeof promptsRaw[number];
return {
...rest,
voteCount: rest._count.votes,
contributorCount: rest._count.contributors,
contributors: rest.contributors,
};
});

return NextResponse.json({
prompts,
Expand All @@ -445,7 +449,7 @@ export async function GET(request: Request) {
totalPages: Math.ceil(total / perPage),
});
} catch (error) {
console.error("List prompts error:", error);
logger.error({ error }, "List prompts error");
return NextResponse.json(
{ error: "server_error", message: "Something went wrong" },
{ status: 500 }
Expand Down
10 changes: 8 additions & 2 deletions src/app/embed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
import DOMPurify from "dompurify";

interface TreeNode {
name: string;
Expand Down Expand Up @@ -479,10 +480,15 @@ function EmbedContent() {
}}
>
{config.prompt ? (
<p
<p
className="text-sm whitespace-pre-wrap"
style={{ color: isDark ? "#fafafa" : "#0f172a" }}
dangerouslySetInnerHTML={{ __html: highlightMentions(config.prompt) }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(highlightMentions(config.prompt), {
ALLOWED_TAGS: ['span'],
ALLOWED_ATTR: ['class'],
}),
}}
/>
) : (
<p
Expand Down
4 changes: 4 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as Sentry from "@sentry/nextjs";
import { validateEnvironment } from "@/lib/config";

export async function register() {
// Validate environment variables at startup
validateEnvironment();

if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
Expand Down
7 changes: 4 additions & 3 deletions src/lib/ai/improve-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from "@/lib/db";
import { generateEmbedding, isAISearchEnabled } from "@/lib/ai/embeddings";
import { loadPrompt, getSystemPrompt, interpolatePrompt } from "@/lib/ai/load-prompt";
import { TYPE_DEFINITIONS } from "@/data/type-definitions";
import { logger } from "@/lib/logger";

const IMPROVE_MODEL = process.env.OPENAI_IMPROVE_MODEL || "gpt-4o";

Expand Down Expand Up @@ -71,7 +72,7 @@ async function findSimilarPrompts(
): Promise<Array<{ id: string; slug: string | null; title: string; content: string; similarity: number }>> {
const aiSearchEnabled = await isAISearchEnabled();
if (!aiSearchEnabled) {
console.log("[improve-prompt] AI search is not enabled");
logger.debug("[improve-prompt] AI search is not enabled");
return [];
}

Expand All @@ -96,7 +97,7 @@ async function findSimilarPrompts(
take: 100,
});

console.log(`[improve-prompt] Found ${prompts.length} prompts with embeddings`);
logger.debug({ count: prompts.length }, "[improve-prompt] Found prompts with embeddings");

const SIMILARITY_THRESHOLD = 0.3;

Expand All @@ -118,7 +119,7 @@ async function findSimilarPrompts(

return scoredPrompts.slice(0, limit);
} catch (error) {
console.error("[improve-prompt] Error finding similar prompts:", error);
logger.error({ error }, "[improve-prompt] Error finding similar prompts");
return [];
}
}
Expand Down
15 changes: 8 additions & 7 deletions src/lib/ai/quality-check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import OpenAI from "openai";
import { loadPrompt, getSystemPrompt } from "./load-prompt";
import { logger, logQualityCheck, logQualityCheckResult, logQualityCheckError } from "@/lib/logger";

const qualityCheckPrompt = loadPrompt("src/lib/ai/quality-check.prompt.yml");

Expand Down Expand Up @@ -74,19 +75,19 @@ export async function checkPromptQuality(
content: string,
description?: string | null
): Promise<QualityCheckResult> {
console.log(`[Quality Check] Checking: "${title}" (${content.length} chars)`);
logQualityCheck(title, content.length);

// First, run basic length checks (no AI needed)
const lengthCheck = checkLength(content);
if (lengthCheck) {
console.log(`[Quality Check] Length check failed:`, lengthCheck);
logQualityCheckResult(lengthCheck);
return lengthCheck;
}

// Check if OpenAI is available
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.log(`[Quality Check] No OpenAI API key - skipping AI check`);
logger.debug("[Quality Check] No OpenAI API key - skipping AI check");
// If no AI available, pass the check (avoid false positives)
return {
shouldDelist: false,
Expand All @@ -96,7 +97,7 @@ export async function checkPromptQuality(
};
}

console.log(`[Quality Check] Running AI check...`);
logger.debug("[Quality Check] Running AI check...");

try {
const client = getOpenAIClient();
Expand All @@ -120,7 +121,7 @@ ${content}`;
});

const responseText = response.choices[0]?.message?.content || "{}";
console.log(`[Quality Check] AI response:`, responseText);
logger.debug({ responseText }, "[Quality Check] AI response");

try {
const result = JSON.parse(responseText);
Expand All @@ -142,7 +143,7 @@ ${content}`;
details: result.details || "Quality check completed",
};
} catch {
console.error("Failed to parse AI quality check response:", responseText);
logQualityCheckError(new Error("Failed to parse AI quality check response"));
// On parse error, don't delist (avoid false positives)
return {
shouldDelist: false,
Expand All @@ -152,7 +153,7 @@ ${content}`;
};
}
} catch (error) {
console.error("AI quality check error:", error);
logQualityCheckError(error);
// On error, don't delist (avoid false positives)
return {
shouldDelist: false,
Expand Down
14 changes: 9 additions & 5 deletions src/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { getConfig } from "@/lib/config";
import { initializePlugins, getAuthPlugin } from "@/lib/plugins";
import type { Adapter, AdapterUser } from "next-auth/adapters";

// Extended interface for adapter user data with optional username fields
interface ExtendedAdapterUser extends AdapterUser {
username?: string;
githubUsername?: string;
}

// Initialize plugins before use
initializePlugins();

Expand Down Expand Up @@ -40,12 +46,10 @@ function CustomPrismaAdapter(): Adapter {

return {
...prismaAdapter,
async createUser(data: AdapterUser & { username?: string; githubUsername?: string }) {
async createUser(data: ExtendedAdapterUser) {
// Use GitHub username if provided, otherwise generate one
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let username = (data as any).username;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const githubUsername = (data as any).githubUsername; // Immutable GitHub username
let username = data.username;
const githubUsername = data.githubUsername;

if (!username) {
username = await generateUsername(data.email, data.name);
Expand Down
36 changes: 36 additions & 0 deletions src/lib/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { logger } from "@/lib/logger";

export interface BrandingConfig {
name: string;
logo: string;
Expand Down Expand Up @@ -224,3 +226,37 @@ export function getConfigSync(): PromptsConfig {
}
return cachedConfig;
}

/**
* Validate required environment variables at startup
* Throws an error if critical environment variables are missing
*/
export function validateEnvironment(): void {
const required = [
'DATABASE_URL',
'NEXTAUTH_SECRET',
];

const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variables: ${missing.join(', ')}. ` +
`Please check your .env file and ensure all required variables are set.`
);
}

// Warn about optional but recommended variables
const recommended = [
'NEXTAUTH_URL',
'OPENAI_API_KEY',
'GOOGLE_ANALYTICS_ID',
];

const missingRecommended = recommended.filter(key => !process.env[key]);
if (missingRecommended.length > 0) {
logger.warn(
`Optional environment variables not set: ${missingRecommended.join(', ')}. ` +
`Some features may be limited.`
);
}
}
48 changes: 48 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import pino from "pino";

const isDevelopment = process.env.NODE_ENV === "development";

export const logger = pino({
level: process.env.LOG_LEVEL || (isDevelopment ? "debug" : "info"),
// Use pretty print in development, JSON in production
transport: isDevelopment
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss Z",
ignore: "pid,hostname",
},
}
: undefined,
// Add error serialization
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err,
},
});

// Convenience methods for common logging patterns
export const logQualityCheck = (title: string, contentLength: number) => {
logger.info({ title, contentLength }, "[Quality Check] Checking prompt");
};

export const logQualityCheckResult = (result: { shouldDelist: boolean; reason: string | null; details: string }) => {
logger.info(result, "[Quality Check] Result");
};

export const logQualityCheckError = (error: unknown) => {
logger.error({ error }, "[Quality Check] Failed");
};

export const logEmbeddingError = (error: unknown, context?: string) => {
logger.error({ error, context }, "[Embedding] Error");
};

export const logWebhookError = (error: unknown, webhookName?: string) => {
logger.error({ error, webhookName }, "[Webhook] Error");
};

export const logAIError = (error: unknown, operation: string) => {
logger.error({ error, operation }, `[AI] ${operation} failed`);
};
8 changes: 0 additions & 8 deletions src/lib/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,3 @@ export async function getConfiguredAuthPlugins() {
return plugins;
}

/**
* @deprecated Use getConfiguredAuthPlugins() instead
* Get the first configured auth plugin based on prompts.config.ts
*/
export async function getConfiguredAuthPlugin() {
const plugins = await getConfiguredAuthPlugins();
return plugins[0];
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"types": [],
"plugins": [
{
"name": "next"
Expand Down