Initial commit: Portfolio Website

Vollständige Next.js 15 Portfolio-Website mit:
- Blog-System mit 100+ Artikeln
- Supabase-Integration
- Responsive Design mit Tailwind CSS
- TypeScript-Konfiguration
- Testing-Setup mit Vitest und Playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 15:07:20 +01:00
co-authored by Claude Opus 4.5
commit e1bbe5455d
315 changed files with 94124 additions and 0 deletions
+463
View File
@@ -0,0 +1,463 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { getAllBlogPosts, getBlogPostBySlug, getBlogPostSlugs, type BlogPost } from './blog';
import fs from 'fs';
import path from 'path';
// Mock fs and path modules
vi.mock('fs');
vi.mock('path');
describe('blog', () => {
const mockBlogPostsDir = '/mock/blog-posts';
beforeEach(() => {
vi.clearAllMocks();
// Mock path.join to return predictable paths
vi.mocked(path.join).mockImplementation((...args) => args.join('/'));
// Mock process.cwd
vi.stubGlobal('process', {
...process,
cwd: () => '/mock'
});
});
describe('getAllBlogPosts', () => {
it('should return empty array when directory does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const posts = getAllBlogPosts();
expect(posts).toEqual([]);
expect(fs.existsSync).toHaveBeenCalled();
});
it('should return empty array when directory is empty', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([]);
const posts = getAllBlogPosts();
expect(posts).toEqual([]);
});
it('should parse and return blog posts from markdown files', () => {
const mockContent = `# Test Blog Post
**Meta-Description:** This is a test blog post about testing.
**Keywords:** testing, vitest, typescript
## Einführung
This is the introduction to the blog post. It provides an overview of what will be covered.
## Main Content
Here is the main content of the blog post.`;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-blog-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts).toHaveLength(1);
expect(posts[0]).toMatchObject({
slug: 'test-blog-post',
title: 'Test Blog Post',
excerpt: 'This is a test blog post about testing.',
tags: ['testing', 'vitest', 'typescript'],
category: 'Web Development'
});
expect(posts[0].date).toBe('2026-01-19');
expect(posts[0].coverImage).toBe('/images/posts/test-blog-post/cover.avif');
expect(posts[0].content).toBe(mockContent);
});
it('should sort posts by date descending (newest first)', () => {
const mockContent1 = '# Post 1\n\n**Meta-Description:** First post';
const mockContent2 = '# Post 2\n\n**Meta-Description:** Second post';
const mockContent3 = '# Post 3\n\n**Meta-Description:** Third post';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-first-post.md',
'002-second-post.md',
'003-third-post.md'
] as any);
vi.mocked(fs.readFileSync)
.mockReturnValueOnce(mockContent1)
.mockReturnValueOnce(mockContent2)
.mockReturnValueOnce(mockContent3);
const posts = getAllBlogPosts();
expect(posts).toHaveLength(3);
// Posts should be sorted newest first (higher numbers = older posts)
expect(posts[0].slug).toBe('first-post');
expect(posts[1].slug).toBe('second-post');
expect(posts[2].slug).toBe('third-post');
expect(new Date(posts[0].date).getTime()).toBeGreaterThanOrEqual(new Date(posts[1].date).getTime());
expect(new Date(posts[1].date).getTime()).toBeGreaterThanOrEqual(new Date(posts[2].date).getTime());
});
it('should filter out non-markdown files', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-test-post.md',
'README.txt',
'image.png',
'002-another-post.md',
'config.json'
] as any);
const mockContent = '# Test\n\n**Meta-Description:** Test';
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts).toHaveLength(2);
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
});
it('should detect category from filename - AI Agents', () => {
const mockContent = '# AI Agent Post\n\n**Meta-Description:** About AI agents';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-agentic-workflow.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].category).toBe('KI-Agenten');
});
it('should detect category from filename - Voice AI', () => {
const mockContent = '# Voice AI Post\n\n**Meta-Description:** About voice technology';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-vapi-integration.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].category).toBe('Voice AI');
});
it('should detect category from content when not in filename', () => {
const mockContent = `# My Post
**Meta-Description:** A post about automation
I love using n8n for automation workflows.`;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-my-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].category).toBe('Automatisierung');
});
it('should use default category when no keywords match', () => {
const mockContent = '# Generic Post\n\n**Meta-Description:** Generic content';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-generic-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].category).toBe('Technologie');
});
it('should extract tags from keywords field', () => {
const mockContent = `# Test Post
**Meta-Description:** Test description
**Keywords:** javascript, typescript, testing, vitest, unit-tests, integration-tests, extra-tag`;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
// Should limit to 6 tags
expect(posts[0].tags).toHaveLength(6);
expect(posts[0].tags).toEqual([
'javascript',
'typescript',
'testing',
'vitest',
'unit-tests',
'integration-tests'
]);
});
it('should handle posts without keywords', () => {
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].tags).toEqual([]);
});
it('should use introduction as excerpt when meta description is missing', () => {
const mockContent = `# Test Post
## Einführung
This is a very long introduction that should be truncated to 200 characters. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.
## Main Content`;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].excerpt).toHaveLength(203); // 200 chars + '...'
expect(posts[0].excerpt).toContain('This is a very long introduction');
expect(posts[0].excerpt).toMatch(/\.\.\.$/);
});
it('should use title as excerpt when both meta description and introduction are missing', () => {
const mockContent = '# Test Post Title\n\nSome content';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].excerpt).toBe('Test Post Title');
});
it('should handle posts without H1 title', () => {
const mockContent = 'No title here\n\n**Meta-Description:** Test';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-fallback-title.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].title).toBe('001-fallback-title');
});
it('should skip posts that fail to parse', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-good-post.md',
'002-bad-post.md'
] as any);
vi.mocked(fs.readFileSync)
.mockReturnValueOnce('# Good Post\n\n**Meta-Description:** Good')
.mockImplementationOnce(() => {
throw new Error('File read error');
});
const posts = getAllBlogPosts();
expect(posts).toHaveLength(1);
expect(posts[0].title).toBe('Good Post');
});
it('should generate correct dates based on post numbers', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-first.md',
'005-fifth.md',
'010-tenth.md'
] as any);
const mockContent = '# Post\n\n**Meta-Description:** Test';
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
// Post 001 should be 2026-01-19
expect(posts.find(p => p.slug === 'first')?.date).toBe('2026-01-19');
// Post 005 should be 4 days earlier (2026-01-15)
expect(posts.find(p => p.slug === 'fifth')?.date).toBe('2026-01-15');
// Post 010 should be 9 days earlier (2026-01-10)
expect(posts.find(p => p.slug === 'tenth')?.date).toBe('2026-01-10');
});
it('should trim whitespace from extracted fields', () => {
const mockContent = `# Test Post With Spaces
**Meta-Description:** Description with spaces
**Keywords:** tag1 , tag2 , tag3 `;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const posts = getAllBlogPosts();
expect(posts[0].title).toBe('Test Post With Spaces');
expect(posts[0].excerpt).toBe('Description with spaces');
expect(posts[0].tags).toEqual(['tag1', 'tag2', 'tag3']);
});
});
describe('getBlogPostBySlug', () => {
it('should return post when slug matches', () => {
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const post = getBlogPostBySlug('test-post');
expect(post).not.toBeNull();
expect(post?.slug).toBe('test-post');
expect(post?.title).toBe('Test Post');
});
it('should return null when slug does not match', () => {
const mockContent = '# Test Post\n\n**Meta-Description:** Test';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-test-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const post = getBlogPostBySlug('non-existent-slug');
expect(post).toBeNull();
});
it('should return null when no posts exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([]);
const post = getBlogPostBySlug('any-slug');
expect(post).toBeNull();
});
it('should return null when directory does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const post = getBlogPostBySlug('any-slug');
expect(post).toBeNull();
});
it('should return correct post when multiple posts exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-first-post.md',
'002-second-post.md',
'003-third-post.md'
] as any);
vi.mocked(fs.readFileSync)
.mockReturnValueOnce('# First Post\n\n**Meta-Description:** First')
.mockReturnValueOnce('# Second Post\n\n**Meta-Description:** Second')
.mockReturnValueOnce('# Third Post\n\n**Meta-Description:** Third');
const post = getBlogPostBySlug('second-post');
expect(post).not.toBeNull();
expect(post?.slug).toBe('second-post');
expect(post?.title).toBe('Second Post');
expect(post?.excerpt).toBe('Second');
});
it('should return post with all content included', () => {
const mockContent = `# Full Post
**Meta-Description:** Description
**Keywords:** tag1, tag2
## Einführung
Introduction text
## Main Content
This is the main content of the post with lots of details.`;
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue(['001-full-post.md'] as any);
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const post = getBlogPostBySlug('full-post');
expect(post?.content).toBe(mockContent);
});
});
describe('getBlogPostSlugs', () => {
it('should return all post slugs', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'001-first-post.md',
'002-second-post.md',
'003-third-post.md'
] as any);
const mockContent = '# Post\n\n**Meta-Description:** Test';
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const slugs = getBlogPostSlugs();
expect(slugs).toHaveLength(3);
expect(slugs).toContain('first-post');
expect(slugs).toContain('second-post');
expect(slugs).toContain('third-post');
});
it('should return empty array when no posts exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([]);
const slugs = getBlogPostSlugs();
expect(slugs).toEqual([]);
});
it('should return empty array when directory does not exist', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const slugs = getBlogPostSlugs();
expect(slugs).toEqual([]);
});
it('should return slugs in date order (newest first)', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readdirSync).mockReturnValue([
'003-third.md',
'001-first.md',
'002-second.md'
] as any);
const mockContent = '# Post\n\n**Meta-Description:** Test';
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
const slugs = getBlogPostSlugs();
expect(slugs).toEqual(['first', 'second', 'third']);
});
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* Blog Post Management Service
* Zentrale Verwaltung aller Blog-Beiträge mit automatischer Kategorie-Erkennung
*/
import fs from 'fs';
import path from 'path';
import { cache } from '@/utils/cache';
/**
* Blog Post Interface
* Definiert die Struktur eines Blog-Beitrags
*/
export interface BlogPost {
/** URL-freundlicher Slug */
slug: string;
/** Titel des Beitrags */
title: string;
/** Veröffentlichungsdatum (ISO 8601) */
date: string;
/** Kurze Zusammenfassung */
excerpt: string;
/** Pfad zum Cover-Bild */
coverImage: string;
/** Kategorie des Beitrags */
category?: string;
/** Schlagwörter/Tags */
tags?: string[];
/** Vollständiger Markdown-Inhalt */
content?: string;
readingTime?: number;
}
// Verzeichnis der Blog-Beiträge
const BLOG_POSTS_DIR = path.join(process.cwd(), 'blog-posts');
/**
* Kategorie-Mapping basierend auf Dateinamen- und Inhaltsmustern
* Ordnet Keywords automatisch passenden Kategorien zu
*/
const categoryMap: Record<string, string> = {
'agentic': 'KI-Agenten',
'ai-agent': 'KI-Agenten',
'deepseek': 'KI-Entwicklung',
'openai': 'KI-Entwicklung',
'gpt': 'KI-Entwicklung',
'claude': 'KI-Entwicklung',
'langchain': 'KI-Entwicklung',
'langgraph': 'KI-Entwicklung',
'rag': 'KI-Entwicklung',
'llm': 'KI-Entwicklung',
'voice': 'Voice AI',
'vapi': 'Voice AI',
'speech': 'Voice AI',
'elevenlabs': 'Voice AI',
'whisper': 'Voice AI',
'n8n': 'Automatisierung',
'make': 'Automatisierung',
'zapier': 'Automatisierung',
'automation': 'Automatisierung',
'workflow': 'Automatisierung',
'scraping': 'Web Scraping',
'playwright': 'Web Scraping',
'puppeteer': 'Web Scraping',
'nextjs': 'Web Development',
'react': 'Web Development',
'typescript': 'Web Development',
'api': 'Backend',
'database': 'Backend',
'postgres': 'Backend',
'prisma': 'Backend',
'supabase': 'Backend',
'saas': 'SaaS',
'stripe': 'SaaS',
'subscription': 'SaaS',
'seo': 'SEO',
'performance': 'Performance',
'iot': 'IoT',
'raspberry': 'IoT',
'mqtt': 'IoT',
'home-assistant': 'Smart Home',
};
/**
* Erkennt automatisch die Kategorie eines Blog-Posts
* Analysiert Dateiname und Inhalt auf Keywords
* @param filename - Dateiname des Blog-Posts
* @param content - Inhalt des Blog-Posts
* @returns Die erkannte Kategorie oder 'Technologie' als Fallback
*/
function detectCategory(filename: string, content: string): string {
const lowerFilename = filename.toLowerCase();
const lowerContent = content.toLowerCase().slice(0, 500);
for (const [keyword, category] of Object.entries(categoryMap)) {
if (lowerFilename.includes(keyword) || lowerContent.includes(keyword)) {
return category;
}
}
return 'Technologie';
}
/**
* Berechnet die geschätzte Lesezeit für einen Blog-Post
* @param content - Der Markdown-Inhalt des Posts
* @returns Lesezeit in Minuten (mindestens 1)
*/
export function calculateReadingTime(content: string): number {
// Remove markdown syntax for more accurate word count
const text = content
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`[^`]*`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images
.replace(/\[.*?\]\(.*?\)/g, '') // Remove links
.replace(/[#*_~]/g, '') // Remove markdown formatting
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
// Count words
const words = text.split(/\s+/).filter(word => word.length > 0).length;
// Calculate reading time (average reading speed: 200 words per minute)
const minutes = Math.ceil(words / 200);
return Math.max(1, minutes); // Minimum 1 minute
}
/**
* Parst einen Markdown-Blog-Post und extrahiert alle Metadaten
* Extrahiert Titel, Meta-Description, Keywords, Kategorie und generiert Slug
* @param filename - Dateiname des Markdown-Posts
* @param content - Vollständiger Markdown-Inhalt
* @returns BlogPost-Objekt oder null bei Fehler
*/
function parseMarkdownPost(filename: string, content: string): BlogPost | null {
try {
// Extract title from first H1
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : filename.replace(/\.md$/, '');
// Extract meta description
const metaMatch = content.match(/\*\*Meta-Description:\*\*\s*(.+)/i);
const metaDescription = metaMatch ? metaMatch[1].trim() : '';
// Extract keywords
const keywordsMatch = content.match(/\*\*Keywords?:\*\*\s*(.+)/i);
const keywordsStr = keywordsMatch ? keywordsMatch[1].trim() : '';
const tags = keywordsStr ? keywordsStr.split(',').map(k => k.trim()).slice(0, 6) : [];
// Extract excerpt from introduction or meta description
let excerpt = metaDescription;
if (!excerpt) {
const introMatch = content.match(/## Einführung\s*\n\n([\s\S]+?)(?:\n\n|$)/);
if (introMatch) {
excerpt = introMatch[1].trim().slice(0, 200) + '...';
}
}
// Generate slug from filename (remove number prefix)
const slug = filename
.replace(/\.md$/, '')
.replace(/^\d+-/, '');
// Extract date from filename number (approximate)
const numMatch = filename.match(/^(\d+)-/);
const postNum = numMatch ? parseInt(numMatch[1], 10) : 1;
// Generate dates starting from 2026-01-19 going backwards
const baseDate = new Date('2026-01-19');
baseDate.setDate(baseDate.getDate() - (postNum - 1));
const date = baseDate.toISOString().split('T')[0];
// Detect category
const category = detectCategory(filename, content);
// Cover image path
const coverImage = `/images/posts/${slug}/cover.avif`;
// Calculate reading time
const readingTime = calculateReadingTime(content);
return {
slug,
title,
date,
excerpt: excerpt || title,
coverImage,
category,
tags,
content,
readingTime,
};
} catch (error) {
console.error(`Error parsing ${filename}:`, error);
return null;
}
}
/**
* Lädt alle Blog-Posts aus dem Dateisystem
* Posts werden nach Datum absteigend sortiert (neueste zuerst)
* @returns Array aller Blog-Posts
*/
export function getAllBlogPosts(): BlogPost[] {
const cacheKey = 'blog:all-posts';
// Check cache first
const cachedPosts = cache.get<BlogPost[]>(cacheKey);
if (cachedPosts) {
return cachedPosts;
}
if (!fs.existsSync(BLOG_POSTS_DIR)) {
console.warn('Blog posts directory not found:', BLOG_POSTS_DIR);
return [];
}
const files = fs.readdirSync(BLOG_POSTS_DIR)
.filter(file => file.endsWith('.md'))
.sort();
const posts: BlogPost[] = [];
for (const file of files) {
try {
const filePath = path.join(BLOG_POSTS_DIR, file);
const content = fs.readFileSync(filePath, 'utf-8');
const post = parseMarkdownPost(file, content);
if (post) {
posts.push(post);
}
} catch (error) {
// Skip files that fail to parse
console.warn(`Failed to parse blog post: ${file}`, error);
continue;
}
}
// Sort by date descending (newest first)
const sortedPosts = posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// Cache the results
cache.set(cacheKey, sortedPosts);
return sortedPosts;
}
/**
* Sucht einen Blog-Post anhand seines Slugs
* @param slug - Der URL-Slug des gesuchten Posts
* @returns Der gefundene Blog-Post oder null
*/
export function getBlogPostBySlug(slug: string): BlogPost | null {
const posts = getAllBlogPosts();
return posts.find(post => post.slug === slug) || null;
}
/**
* Gibt alle verfügbaren Blog-Post Slugs zurück
* Nützlich für statische Seitengenerierung
* @returns Array aller Post-Slugs
*/
export function getBlogPostSlugs(): string[] {
return getAllBlogPosts().map(post => post.slug);
}
+17
View File
@@ -0,0 +1,17 @@
const CSRF_META_TAG_NAME = 'csrf-token';
/**
* Get the CSRF token from the meta tag in the document head
* The server should render: <meta name="csrf-token" content="..." />
*/
export function getCsrfToken(): string | null {
if (typeof document === 'undefined') {
return null;
}
const metaTag = document.querySelector<HTMLMetaElement>(
`meta[name="${CSRF_META_TAG_NAME}"]`
);
return metaTag?.content || null;
}
+94
View File
@@ -0,0 +1,94 @@
import { cookies } from 'next/headers';
import { randomBytes } from 'crypto';
const CSRF_TOKEN_COOKIE_NAME = 'csrf_token';
const CSRF_TOKEN_LENGTH = 32;
/**
* Generate a cryptographically secure CSRF token
*/
export function generateCsrfToken(): string {
return randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
}
/**
* Get the current CSRF token from cookies or generate a new one
*/
export async function getCsrfToken(): Promise<string> {
const cookieStore = await cookies();
const existingToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (existingToken?.value) {
return existingToken.value;
}
const newToken = generateCsrfToken();
await setCsrfToken(newToken);
return newToken;
}
/**
* Set the CSRF token in an httpOnly cookie
*/
export async function setCsrfToken(token: string): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.set(CSRF_TOKEN_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
} catch {
// Handle server component context where cookies can't be set
}
}
/**
* Validate a CSRF token against the stored token in cookies
*/
export async function validateCsrfToken(token: string): Promise<boolean> {
const cookieStore = await cookies();
const storedToken = cookieStore.get(CSRF_TOKEN_COOKIE_NAME);
if (!storedToken?.value || !token) {
return false;
}
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(
Buffer.from(storedToken.value),
Buffer.from(token)
);
}
/**
* Timing-safe string comparison to prevent timing attacks
*/
function timingSafeEqual(a: Buffer, b: Buffer): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
/**
* Delete the CSRF token cookie
*/
export async function deleteCsrfToken(): Promise<void> {
const cookieStore = await cookies();
try {
cookieStore.delete(CSRF_TOKEN_COOKIE_NAME);
} catch {
// Handle server component context
}
}
+262
View File
@@ -0,0 +1,262 @@
import { query, queryOne } from './postgres';
// Type definitions for chat tables
export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatSession {
id: string;
created_at: string;
updated_at: string;
visitor_id: string;
metadata: Record<string, unknown>;
}
export interface ChatMessage {
id: string;
session_id: string;
role: ChatRole;
content: string;
created_at: string;
}
// Input types for creating records
export interface CreateChatSessionInput {
visitor_id: string;
metadata?: Record<string, unknown>;
}
export interface CreateChatMessageInput {
session_id: string;
role: ChatRole;
content: string;
}
// ============================================
// Chat Session Operations
// ============================================
/**
* Create a new chat session for a visitor
*/
export async function createChatSession(
input: CreateChatSessionInput
): Promise<ChatSession> {
const result = await queryOne<ChatSession>(
`INSERT INTO chat_sessions (visitor_id, metadata)
VALUES ($1, $2)
RETURNING *`,
[input.visitor_id, JSON.stringify(input.metadata ?? {})]
);
if (!result) {
throw new Error('Failed to create chat session');
}
return result;
}
/**
* Get a chat session by ID
*/
export async function getChatSession(
sessionId: string
): Promise<ChatSession | null> {
return queryOne<ChatSession>(
'SELECT * FROM chat_sessions WHERE id = $1',
[sessionId]
);
}
/**
* Get or create a chat session for a visitor
* Useful for maintaining session continuity
*/
export async function getOrCreateChatSession(
visitorId: string,
metadata?: Record<string, unknown>
): Promise<ChatSession> {
// First, try to find an existing active session for this visitor
// Get the most recent session from the last 24 hours
const existingSession = await queryOne<ChatSession>(
`SELECT * FROM chat_sessions
WHERE visitor_id = $1
AND created_at >= NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC
LIMIT 1`,
[visitorId]
);
if (existingSession) {
return existingSession;
}
// No existing session found, create a new one
return createChatSession({
visitor_id: visitorId,
metadata,
});
}
/**
* Update session metadata
*/
export async function updateSessionMetadata(
sessionId: string,
metadata: Record<string, unknown>
): Promise<ChatSession> {
const result = await queryOne<ChatSession>(
`UPDATE chat_sessions
SET metadata = $1
WHERE id = $2
RETURNING *`,
[JSON.stringify(metadata), sessionId]
);
if (!result) {
throw new Error('Failed to update session metadata');
}
return result;
}
// ============================================
// Chat Message Operations
// ============================================
/**
* Add a message to a chat session
*/
export async function addChatMessage(
input: CreateChatMessageInput
): Promise<ChatMessage> {
const result = await queryOne<ChatMessage>(
`INSERT INTO chat_messages (session_id, role, content)
VALUES ($1, $2, $3)
RETURNING *`,
[input.session_id, input.role, input.content]
);
if (!result) {
throw new Error('Failed to add chat message');
}
return result;
}
/**
* Get all messages for a chat session
* Returns messages in chronological order
*/
export async function getChatMessages(
sessionId: string
): Promise<ChatMessage[]> {
return query<ChatMessage>(
`SELECT * FROM chat_messages
WHERE session_id = $1
ORDER BY created_at ASC`,
[sessionId]
);
}
/**
* Get the last N messages for a session
* Useful for maintaining context window limits
*/
export async function getRecentMessages(
sessionId: string,
limit: number = 20
): Promise<ChatMessage[]> {
const messages = await query<ChatMessage>(
`SELECT * FROM chat_messages
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[sessionId, limit]
);
// Reverse to get chronological order
return messages.reverse();
}
/**
* Delete all messages for a session (clear history)
*/
export async function clearChatMessages(sessionId: string): Promise<void> {
await query(
'DELETE FROM chat_messages WHERE session_id = $1',
[sessionId]
);
}
/**
* Delete a chat session and all its messages
* Messages are deleted automatically via ON DELETE CASCADE
*/
export async function deleteChatSession(sessionId: string): Promise<void> {
await query(
'DELETE FROM chat_sessions WHERE id = $1',
[sessionId]
);
}
// ============================================
// Utility Functions
// ============================================
/**
* Get conversation history formatted for AI context
* Converts database messages to the format expected by chat completion APIs
*/
export async function getConversationHistory(
sessionId: string,
maxMessages: number = 20
): Promise<Array<{ role: ChatRole; content: string }>> {
const messages = await getRecentMessages(sessionId, maxMessages);
return messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
}
/**
* Save a conversation turn (user message + assistant response)
* Convenience function for typical chat flow
*/
export async function saveConversationTurn(
sessionId: string,
userMessage: string,
assistantResponse: string
): Promise<{ userMsg: ChatMessage; assistantMsg: ChatMessage }> {
const userMsg = await addChatMessage({
session_id: sessionId,
role: 'user',
content: userMessage,
});
const assistantMsg = await addChatMessage({
session_id: sessionId,
role: 'assistant',
content: assistantResponse,
});
return { userMsg, assistantMsg };
}
// Default export for convenience
export default {
// Session operations
createChatSession,
getChatSession,
getOrCreateChatSession,
updateSessionMetadata,
deleteChatSession,
// Message operations
addChatMessage,
getChatMessages,
getRecentMessages,
clearChatMessages,
// Utility functions
getConversationHistory,
saveConversationTurn,
};
+2
View File
@@ -0,0 +1,2 @@
export * from './postgres';
export * from './chat';
+81
View File
@@ -0,0 +1,81 @@
import { Pool, PoolClient } from 'pg';
// Singleton pool instance
let pool: Pool | null = null;
/**
* Get or create a PostgreSQL connection pool
*/
export function getPool(): Pool {
if (!pool) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('Missing DATABASE_URL environment variable');
}
pool = new Pool({
connectionString,
max: 10, // Maximum number of connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : undefined,
});
// Handle pool errors
pool.on('error', (err) => {
console.error('Unexpected PostgreSQL pool error:', err);
});
}
return pool;
}
/**
* Execute a query with automatic connection management
*/
export async function query<T = unknown>(
text: string,
params?: unknown[]
): Promise<T[]> {
const pool = getPool();
const result = await pool.query(text, params);
return result.rows as T[];
}
/**
* Execute a query and return a single row
*/
export async function queryOne<T = unknown>(
text: string,
params?: unknown[]
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] ?? null;
}
/**
* Get a client for transactions
*/
export async function getClient(): Promise<PoolClient> {
const pool = getPool();
return pool.connect();
}
/**
* Close the pool (for graceful shutdown)
*/
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}
export default {
getPool,
query,
queryOne,
getClient,
closePool,
};
+92
View File
@@ -0,0 +1,92 @@
import OpenAI from 'openai';
// Deepseek configuration constants
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
const DEEPSEEK_DEFAULT_MODEL = 'deepseek-chat';
// Create Deepseek client using OpenAI SDK
// The client is lazy-initialized to avoid issues during build time
let deepseekClient: OpenAI | null = null;
export function createDeepseekClient(): OpenAI {
if (!process.env.DEEPSEEK_API_KEY) {
throw new Error('DEEPSEEK_API_KEY environment variable is not set');
}
if (!deepseekClient) {
deepseekClient = new OpenAI({
baseURL: DEEPSEEK_BASE_URL,
apiKey: process.env.DEEPSEEK_API_KEY,
});
}
return deepseekClient;
}
// Type definitions for chat messages
export type ChatRole = 'system' | 'user' | 'assistant';
export interface ChatMessage {
role: ChatRole;
content: string;
}
// System prompt for the portfolio chatbot
export const PORTFOLIO_SYSTEM_PROMPT = `You are a helpful AI assistant on Damjan Savić's portfolio website.
Damjan is a full-stack web developer and AI specialist based in Vienna, Austria.
You can help visitors learn about Damjan's skills, services, and experience.
Be friendly, professional, and concise in your responses.
If asked about topics unrelated to Damjan or web development, politely redirect the conversation.
Respond in the same language the user writes to you (German, English, or Serbian).`;
// Chat completion options
export interface ChatCompletionOptions {
messages: ChatMessage[];
model?: string;
temperature?: number;
maxTokens?: number;
stream?: boolean;
}
// Create a chat completion (non-streaming)
export async function createChatCompletion(
options: ChatCompletionOptions
): Promise<string> {
const client = createDeepseekClient();
const completion = await client.chat.completions.create({
model: options.model ?? DEEPSEEK_DEFAULT_MODEL,
messages: options.messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.maxTokens ?? 1024,
stream: false,
});
return completion.choices[0]?.message?.content ?? '';
}
// Create a streaming chat completion
export async function createStreamingChatCompletion(
options: Omit<ChatCompletionOptions, 'stream'>
): Promise<AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>> {
const client = createDeepseekClient();
const stream = await client.chat.completions.create({
model: options.model ?? DEEPSEEK_DEFAULT_MODEL,
messages: options.messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.maxTokens ?? 1024,
stream: true,
});
return stream;
}
// Default export for convenience
export default {
createClient: createDeepseekClient,
createCompletion: createChatCompletion,
createStreamingCompletion: createStreamingChatCompletion,
SYSTEM_PROMPT: PORTFOLIO_SYSTEM_PROMPT,
DEFAULT_MODEL: DEEPSEEK_DEFAULT_MODEL,
};
+324
View File
@@ -0,0 +1,324 @@
/**
* Markdown Rendering Utility
* Converts markdown text to styled React components
*/
import React from 'react';
/**
* Parse inline markdown syntax (code, bold, italic, links)
* Converts inline markdown elements within a text string to React components
*/
function parseInlineMarkdown(text: string): React.ReactNode {
const parts: React.ReactNode[] = [];
let remaining = text;
let keyIndex = 0;
while (remaining.length > 0) {
// Inline code
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
parts.push(
<code key={keyIndex++} className="bg-zinc-800 px-1.5 py-0.5 rounded text-[#697565] text-sm font-mono">
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// Bold
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/);
if (boldMatch) {
parts.push(<strong key={keyIndex++} className="font-semibold text-white">{boldMatch[1]}</strong>);
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// Italic
const italicMatch = remaining.match(/^\*([^*]+)\*/);
if (italicMatch) {
parts.push(<em key={keyIndex++} className="italic">{italicMatch[1]}</em>);
remaining = remaining.slice(italicMatch[0].length);
continue;
}
// Links
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
parts.push(
<a key={keyIndex++} href={linkMatch[2]} className="text-[#697565] hover:text-[#8a9a7e] underline" target="_blank" rel="noopener noreferrer">
{linkMatch[1]}
</a>
);
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// Regular character
const nextSpecial = remaining.search(/[`*\[]/);
if (nextSpecial === -1) {
parts.push(remaining);
break;
} else if (nextSpecial === 0) {
parts.push(remaining[0]);
remaining = remaining.slice(1);
} else {
parts.push(remaining.slice(0, nextSpecial));
remaining = remaining.slice(nextSpecial);
}
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
/**
* Render markdown content to React components
* Supports headers, lists, code blocks, tables, blockquotes, and inline formatting
*/
export function renderMarkdown(content: string): React.ReactNode {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
let i = 0;
let listItems: { type: 'ul' | 'ol'; items: React.ReactNode[] } | null = null;
let codeBlock: { language: string; lines: string[] } | null = null;
let tableRows: string[][] | null = null;
const flushList = () => {
if (listItems) {
if (listItems.type === 'ul') {
elements.push(
<ul key={`ul-${elements.length}`} className="list-disc list-inside space-y-2 my-4 text-zinc-300">
{listItems.items}
</ul>
);
} else {
elements.push(
<ol key={`ol-${elements.length}`} className="list-decimal list-inside space-y-2 my-4 text-zinc-300">
{listItems.items}
</ol>
);
}
listItems = null;
}
};
const flushTable = () => {
if (tableRows && tableRows.length > 0) {
const header = tableRows[0];
const body = tableRows.slice(2); // Skip header separator row
elements.push(
<div key={`table-${elements.length}`} className="overflow-x-auto my-6">
<table className="min-w-full border-collapse">
<thead>
<tr className="border-b border-zinc-700">
{header.map((cell, idx) => (
<th key={idx} className="px-4 py-2 text-left text-zinc-200 font-semibold">
{cell.trim()}
</th>
))}
</tr>
</thead>
<tbody>
{body.map((row, rowIdx) => (
<tr key={rowIdx} className="border-b border-zinc-800">
{row.map((cell, cellIdx) => (
<td key={cellIdx} className="px-4 py-2 text-zinc-300">
{cell.trim()}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
tableRows = null;
}
};
while (i < lines.length) {
const line = lines[i];
// Code block start/end
if (line.startsWith('```')) {
if (codeBlock) {
// End code block
elements.push(
<pre key={`code-${elements.length}`} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 overflow-x-auto my-6">
<code className="text-sm font-mono text-zinc-300">
{codeBlock.lines.join('\n')}
</code>
</pre>
);
codeBlock = null;
} else {
// Start code block
flushList();
flushTable();
const language = line.slice(3).trim();
codeBlock = { language, lines: [] };
}
i++;
continue;
}
// Inside code block
if (codeBlock) {
codeBlock.lines.push(line);
i++;
continue;
}
// Table row
if (line.startsWith('|') && line.endsWith('|')) {
flushList();
const cells = line.slice(1, -1).split('|');
if (!tableRows) {
tableRows = [];
}
tableRows.push(cells);
i++;
continue;
} else if (tableRows) {
flushTable();
}
// Headers
if (line.startsWith('#### ')) {
flushList();
elements.push(
<h4 key={`h4-${elements.length}`} className="text-lg font-semibold text-white mt-5 mb-2">
{parseInlineMarkdown(line.slice(5))}
</h4>
);
i++;
continue;
}
if (line.startsWith('### ')) {
flushList();
elements.push(
<h3 key={`h3-${elements.length}`} className="text-xl font-semibold text-white mt-6 mb-3">
{parseInlineMarkdown(line.slice(4))}
</h3>
);
i++;
continue;
}
if (line.startsWith('## ')) {
flushList();
elements.push(
<h2 key={`h2-${elements.length}`} className="text-2xl font-bold text-white mt-8 mb-4">
{parseInlineMarkdown(line.slice(3))}
</h2>
);
i++;
continue;
}
if (line.startsWith('# ')) {
flushList();
elements.push(
<h1 key={`h1-${elements.length}`} className="text-3xl font-bold text-white mt-8 mb-4">
{parseInlineMarkdown(line.slice(2))}
</h1>
);
i++;
continue;
}
// Unordered list items
if (line.match(/^[-*]\s/)) {
if (!listItems || listItems.type !== 'ul') {
flushList();
listItems = { type: 'ul', items: [] };
}
listItems.items.push(
<li key={`li-${listItems.items.length}`}>
{parseInlineMarkdown(line.replace(/^[-*]\s/, ''))}
</li>
);
i++;
continue;
}
// Ordered list items
if (line.match(/^\d+\.\s/)) {
if (!listItems || listItems.type !== 'ol') {
flushList();
listItems = { type: 'ol', items: [] };
}
listItems.items.push(
<li key={`li-${listItems.items.length}`}>
{parseInlineMarkdown(line.replace(/^\d+\.\s/, ''))}
</li>
);
i++;
continue;
}
// Checkbox list items
if (line.match(/^[-*]\s\[[ x]\]/i)) {
if (!listItems || listItems.type !== 'ul') {
flushList();
listItems = { type: 'ul', items: [] };
}
const checked = line.match(/^[-*]\s\[x\]/i);
const itemContent = line.replace(/^[-*]\s\[[ x]\]\s?/i, '');
listItems.items.push(
<li key={`li-${listItems.items.length}`} className="flex items-center gap-2">
<span className={checked ? 'text-green-500' : 'text-zinc-500'}>
{checked ? '✓' : '○'}
</span>
{parseInlineMarkdown(itemContent)}
</li>
);
i++;
continue;
}
// Horizontal rule
if (line.match(/^[-*_]{3,}$/)) {
flushList();
elements.push(<hr key={`hr-${elements.length}`} className="border-zinc-700 my-8" />);
i++;
continue;
}
// Blockquote
if (line.startsWith('> ')) {
flushList();
elements.push(
<blockquote key={`quote-${elements.length}`} className="border-l-4 border-[#697565] pl-4 my-4 text-zinc-400 italic">
{parseInlineMarkdown(line.slice(2))}
</blockquote>
);
i++;
continue;
}
// Empty line
if (!line.trim()) {
flushList();
i++;
continue;
}
// Regular paragraph
flushList();
elements.push(
<p key={`p-${elements.length}`} className="text-zinc-300 mb-4 leading-relaxed">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
// Flush any remaining items
flushList();
flushTable();
return <div className="prose prose-invert prose-zinc max-w-none">{elements}</div>;
}
+335
View File
@@ -0,0 +1,335 @@
import { createClient as createServerClient } from '@supabase/supabase-js';
// Type definitions for chat tables
export type ChatRole = 'user' | 'assistant' | 'system';
export interface ChatSession {
id: string;
created_at: string;
updated_at: string;
visitor_id: string;
metadata: Record<string, unknown>;
}
export interface ChatMessage {
id: string;
session_id: string;
role: ChatRole;
content: string;
created_at: string;
}
// Input types for creating records
export interface CreateChatSessionInput {
visitor_id: string;
metadata?: Record<string, unknown>;
}
export interface CreateChatMessageInput {
session_id: string;
role: ChatRole;
content: string;
}
// Create a Supabase client with service role key for server-side operations
// This bypasses RLS for administrative operations
function createServiceClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
throw new Error(
'Missing Supabase environment variables: NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'
);
}
return createServerClient(supabaseUrl, serviceRoleKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
});
}
// ============================================
// Chat Session Operations
// ============================================
/**
* Create a new chat session for a visitor
*/
export async function createChatSession(
input: CreateChatSessionInput
): Promise<ChatSession> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_sessions')
.insert({
visitor_id: input.visitor_id,
metadata: input.metadata ?? {},
})
.select()
.single();
if (error) {
throw new Error(`Failed to create chat session: ${error.message}`);
}
return data as ChatSession;
}
/**
* Get a chat session by ID
*/
export async function getChatSession(
sessionId: string
): Promise<ChatSession | null> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_sessions')
.select()
.eq('id', sessionId)
.single();
if (error) {
if (error.code === 'PGRST116') {
// No rows found
return null;
}
throw new Error(`Failed to get chat session: ${error.message}`);
}
return data as ChatSession;
}
/**
* Get or create a chat session for a visitor
* Useful for maintaining session continuity
*/
export async function getOrCreateChatSession(
visitorId: string,
metadata?: Record<string, unknown>
): Promise<ChatSession> {
const supabase = createServiceClient();
// First, try to find an existing active session for this visitor
// Get the most recent session from the last 24 hours
const twentyFourHoursAgo = new Date(
Date.now() - 24 * 60 * 60 * 1000
).toISOString();
const { data: existingSession, error: findError } = await supabase
.from('chat_sessions')
.select()
.eq('visitor_id', visitorId)
.gte('created_at', twentyFourHoursAgo)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (existingSession && !findError) {
return existingSession as ChatSession;
}
// No existing session found, create a new one
return createChatSession({
visitor_id: visitorId,
metadata,
});
}
/**
* Update session metadata
*/
export async function updateSessionMetadata(
sessionId: string,
metadata: Record<string, unknown>
): Promise<ChatSession> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_sessions')
.update({ metadata })
.eq('id', sessionId)
.select()
.single();
if (error) {
throw new Error(`Failed to update session metadata: ${error.message}`);
}
return data as ChatSession;
}
// ============================================
// Chat Message Operations
// ============================================
/**
* Add a message to a chat session
*/
export async function addChatMessage(
input: CreateChatMessageInput
): Promise<ChatMessage> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_messages')
.insert({
session_id: input.session_id,
role: input.role,
content: input.content,
})
.select()
.single();
if (error) {
throw new Error(`Failed to add chat message: ${error.message}`);
}
return data as ChatMessage;
}
/**
* Get all messages for a chat session
* Returns messages in chronological order
*/
export async function getChatMessages(
sessionId: string
): Promise<ChatMessage[]> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_messages')
.select()
.eq('session_id', sessionId)
.order('created_at', { ascending: true });
if (error) {
throw new Error(`Failed to get chat messages: ${error.message}`);
}
return (data ?? []) as ChatMessage[];
}
/**
* Get the last N messages for a session
* Useful for maintaining context window limits
*/
export async function getRecentMessages(
sessionId: string,
limit: number = 20
): Promise<ChatMessage[]> {
const supabase = createServiceClient();
const { data, error } = await supabase
.from('chat_messages')
.select()
.eq('session_id', sessionId)
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
throw new Error(`Failed to get recent messages: ${error.message}`);
}
// Reverse to get chronological order
return ((data ?? []) as ChatMessage[]).reverse();
}
/**
* Delete all messages for a session (clear history)
*/
export async function clearChatMessages(sessionId: string): Promise<void> {
const supabase = createServiceClient();
const { error } = await supabase
.from('chat_messages')
.delete()
.eq('session_id', sessionId);
if (error) {
throw new Error(`Failed to clear chat messages: ${error.message}`);
}
}
/**
* Delete a chat session and all its messages
* Messages are deleted automatically via ON DELETE CASCADE
*/
export async function deleteChatSession(sessionId: string): Promise<void> {
const supabase = createServiceClient();
const { error } = await supabase
.from('chat_sessions')
.delete()
.eq('id', sessionId);
if (error) {
throw new Error(`Failed to delete chat session: ${error.message}`);
}
}
// ============================================
// Utility Functions
// ============================================
/**
* Get conversation history formatted for AI context
* Converts database messages to the format expected by chat completion APIs
*/
export async function getConversationHistory(
sessionId: string,
maxMessages: number = 20
): Promise<Array<{ role: ChatRole; content: string }>> {
const messages = await getRecentMessages(sessionId, maxMessages);
return messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
}
/**
* Save a conversation turn (user message + assistant response)
* Convenience function for typical chat flow
*/
export async function saveConversationTurn(
sessionId: string,
userMessage: string,
assistantResponse: string
): Promise<{ userMsg: ChatMessage; assistantMsg: ChatMessage }> {
const userMsg = await addChatMessage({
session_id: sessionId,
role: 'user',
content: userMessage,
});
const assistantMsg = await addChatMessage({
session_id: sessionId,
role: 'assistant',
content: assistantResponse,
});
return { userMsg, assistantMsg };
}
// Default export for convenience
export default {
// Session operations
createChatSession,
getChatSession,
getOrCreateChatSession,
updateSessionMetadata,
deleteChatSession,
// Message operations
addChatMessage,
getChatMessages,
getRecentMessages,
clearChatMessages,
// Utility functions
getConversationHistory,
saveConversationTurn,
};
+17
View File
@@ -0,0 +1,17 @@
/**
* Supabase Browser Client
* Client-side Supabase client for authentication and database access
*/
import { createBrowserClient } from '@supabase/ssr';
/**
* Creates a Supabase browser client instance
* @returns Supabase client configured with environment variables
*/
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
+25
View File
@@ -0,0 +1,25 @@
import { createServerClient } from '@supabase/ssr';
import { NextRequest, NextResponse } from 'next/server';
export function createClient(request: NextRequest, response: NextResponse) {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
} catch {
// Handle middleware context
}
},
},
}
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Supabase Server Client Utility
* Creates server-side Supabase clients with cookie handling for SSR
*/
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
/**
* Creates a Supabase client for server-side usage
* Handles cookie management for authentication in Next.js server components
*
* @returns {Promise} Supabase server client instance
*/
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Handle server component context
}
},
},
}
);
}