Files
portfolio/app/api/n8n/chat/route.ts
denshooter 377631ee50 Copilot/setup sentry nextjs (#58)
* Revise portfolio: warm brown theme, elegant typography, optimized analytics tracking (#55)

* Initial plan

* Update color theme to warm brown and off-white, add elegant fonts, fix analytics tracking

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix 404 page integration with warm theme, update admin console colors, fix font loading

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Address code review feedback: fix navigation, add utils, improve tracking

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix accessibility and memory leak issues from code review

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* chore: Code cleanup, add Sentry.io monitoring, and documentation (#56)

* Initial plan

* Remove unused code and clean up console statements

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Remove unused components and fix type issues

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Wrap console.warn in development check

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Integrate Sentry.io monitoring and add text editing documentation

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Initial plan

* feat: Add Sentry configuration files and example pages

- Add sentry.server.config.ts and sentry.edge.config.ts
- Update instrumentation.ts with onRequestError export
- Update instrumentation-client.ts with onRouterTransitionStart export
- Update global-error.tsx to capture exceptions with Sentry
- Create Sentry example page at app/sentry-example-page/page.tsx
- Create Sentry example API route at app/api/sentry-example-api/route.ts

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* feat: Update middleware to allow Sentry example page and fix deprecated API

- Update middleware to exclude /sentry-example-page from locale routing
- Remove deprecated startTransaction API from Sentry example page
- Use consistent DSN configuration with fallback values

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* refactor: Improve Sentry configuration with environment-based sampling

- Add comments explaining DSN fallback values
- Use environment-based tracesSampleRate (10% in production, 100% in dev)
- Address code review feedback for production-safe configuration

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-01-22 10:05:43 +01:00

309 lines
12 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
export async function POST(request: NextRequest) {
let userMessage = "";
try {
// Rate limiting for n8n chat endpoint
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
const json = await request.json();
userMessage = json.message;
const history = json.history || [];
if (!userMessage || typeof userMessage !== "string") {
return NextResponse.json(
{ error: "Message is required" },
{ status: 400 },
);
}
// Call your n8n chat webhook
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl || n8nWebhookUrl.trim() === '') {
console.error("N8N_WEBHOOK_URL not configured. Environment check:", {
hasUrl: !!process.env.N8N_WEBHOOK_URL,
urlValue: process.env.N8N_WEBHOOK_URL || '(empty)',
nodeEnv: process.env.NODE_ENV,
});
return NextResponse.json({
reply: getFallbackResponse(userMessage),
});
}
// Ensure URL doesn't have trailing slash before adding /webhook/chat
const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/webhook/chat`;
if (process.env.NODE_ENV === 'development') {
console.log(`Sending to n8n: ${webhookUrl}`, {
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
});
}
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
...(process.env.N8N_API_KEY && {
"X-API-Key": process.env.N8N_API_KEY,
}),
},
body: JSON.stringify({
message: userMessage,
history: history,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
if (process.env.NODE_ENV === 'development') {
console.error(`n8n webhook failed with status: ${response.status}`, {
status: response.status,
statusText: response.statusText,
error: errorText,
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
});
}
throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
if (process.env.NODE_ENV === 'development') {
console.log("n8n response data (full):", JSON.stringify(data, null, 2));
console.log("n8n response data type:", typeof data);
console.log("n8n response is array:", Array.isArray(data));
}
// Try multiple ways to extract the reply
let reply: string | undefined = undefined;
// Direct fields
if (data.reply) reply = data.reply;
else if (data.message) reply = data.message;
else if (data.response) reply = data.response;
else if (data.text) reply = data.text;
else if (data.content) reply = data.content;
else if (data.answer) reply = data.answer;
else if (data.output) reply = data.output;
else if (data.result) reply = data.result;
// Array handling
else if (Array.isArray(data) && data.length > 0) {
const firstItem = data[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.output || firstItem.result;
}
}
// Nested structures (common in n8n)
else if (data && typeof data === "object") {
// Check nested data field
if (data.data) {
if (typeof data.data === 'string') {
reply = data.data;
} else if (typeof data.data === 'object') {
reply = data.data.reply || data.data.message || data.data.response ||
data.data.text || data.data.content || data.data.answer;
}
}
// Check nested json field
if (!reply && data.json) {
if (typeof data.json === 'string') {
reply = data.json;
} else if (typeof data.json === 'object') {
reply = data.json.reply || data.json.message || data.json.response ||
data.json.text || data.json.content || data.json.answer;
}
}
// Check items array (n8n often wraps in items)
if (!reply && Array.isArray(data.items) && data.items.length > 0) {
const firstItem = data.items[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.json?.reply || firstItem.json?.message;
}
}
// Last resort: if it's a single string value object, try to extract
if (!reply && Object.keys(data).length === 1) {
const value = Object.values(data)[0];
if (typeof value === 'string') {
reply = value;
}
}
// If still no reply but data exists, stringify it (for debugging)
if (!reply && Object.keys(data).length > 0) {
console.warn("n8n response structure not recognized, attempting to extract any string value");
// Try to find any string value in the object
const findStringValue = (obj: unknown): string | undefined => {
if (typeof obj === 'string' && obj.length > 0) return obj;
if (Array.isArray(obj) && obj.length > 0) {
return findStringValue(obj[0]);
}
if (obj && typeof obj === 'object' && obj !== null) {
const objRecord = obj as Record<string, unknown>;
for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) {
if (objRecord[key] && typeof objRecord[key] === 'string') {
return objRecord[key] as string;
}
}
// Recursively search
for (const value of Object.values(objRecord)) {
const found = findStringValue(value);
if (found) return found;
}
}
return undefined;
};
reply = findStringValue(data);
}
}
if (!reply) {
console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2));
throw new Error("Invalid response format from n8n - no reply field found");
}
// Decode HTML entities in the reply
const decodedReply = decodeHtmlEntitiesServer(String(reply));
return NextResponse.json({
reply: decodedReply,
});
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n webhook request timed out");
} else {
console.error("n8n webhook fetch error:", fetchError);
}
throw fetchError;
}
} catch (error: unknown) {
console.error("Chat API error:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? `configured (${process.env.N8N_WEBHOOK_URL})` : 'missing',
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
nodeEnv: process.env.NODE_ENV,
});
// Fallback to mock responses
// Now using the variable captured at the start
return NextResponse.json({ reply: getFallbackResponse(userMessage) });
}
}
function getFallbackResponse(message: string): string {
if (!message || typeof message !== "string") {
return "I'm having a bit of trouble understanding. Could you try asking again?";
}
const lowerMessage = message.toLowerCase();
if (
lowerMessage.includes("skill") ||
lowerMessage.includes("tech") ||
lowerMessage.includes("stack")
) {
return "I specialize in full-stack development with Next.js, React, and Flutter for mobile. On the DevOps side, I love working with Docker Swarm, Traefik, and CI/CD pipelines. Basically, if it involves code or servers, I'm interested!";
}
if (
lowerMessage.includes("project") ||
lowerMessage.includes("built") ||
lowerMessage.includes("work")
) {
return "One of my key projects is Clarity, a Flutter app designed to help people with dyslexia. I also maintain a comprehensive self-hosted infrastructure with Docker Swarm. You can check out more details in the Projects section!";
}
if (
lowerMessage.includes("contact") ||
lowerMessage.includes("email") ||
lowerMessage.includes("reach") ||
lowerMessage.includes("hire")
) {
return "The best way to reach me is through the contact form below or by emailing contact@dk0.dev. I'm always open to discussing new ideas, opportunities, or just chatting about tech!";
}
if (
lowerMessage.includes("location") ||
lowerMessage.includes("where") ||
lowerMessage.includes("live")
) {
return "I'm based in Osnabrück, Germany. It's a great place to be a student and work on tech projects!";
}
if (
lowerMessage.includes("hobby") ||
lowerMessage.includes("free time") ||
lowerMessage.includes("fun")
) {
return "When I'm not coding or tweaking my servers, I enjoy gaming, going for a jog, or experimenting with new tech. Fun fact: I still use pen and paper for my calendar, even though I automate everything else!";
}
if (
lowerMessage.includes("devops") ||
lowerMessage.includes("docker") ||
lowerMessage.includes("server") ||
lowerMessage.includes("hosting")
) {
return "I'm really into DevOps! I run my own infrastructure on IONOS and OVHcloud using Docker Swarm and Traefik. It allows me to host various services and game servers efficiently while learning a ton about system administration.";
}
if (
lowerMessage.includes("student") ||
lowerMessage.includes("study") ||
lowerMessage.includes("education")
) {
return "Yes, I'm currently a student in Osnabrück. I balance my studies with working on personal projects and managing my self-hosted infrastructure. It keeps me busy but I learn something new every day!";
}
if (
lowerMessage.includes("hello") ||
lowerMessage.includes("hi ") ||
lowerMessage.includes("hey")
) {
return "Hi there! I'm Dennis's AI assistant (currently in offline mode). How can I help you learn more about Dennis today?";
}
// Default response
return "That's an interesting question! I'm currently operating in fallback mode, so my knowledge is a bit limited right now. But I can tell you that Dennis is a full-stack developer and DevOps enthusiast who loves building with Next.js and Docker. Feel free to ask about his skills, projects, or how to contact him!";
}