diff --git a/dist-server/server.js b/dist-server/server.js new file mode 100644 index 0000000..1507e31 --- /dev/null +++ b/dist-server/server.js @@ -0,0 +1,149 @@ +import express from 'express'; +import nodemailer from 'nodemailer'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const app = express(); +app.use(express.json()); +// Serve static files from Vite build +app.use(express.static(path.join(__dirname, 'dist'))); +// Contact API endpoint +app.post('/api/contact', async (req, res) => { + const { name, organisation, email, subject, message } = req.body; + if (!name || !email || !subject || !message) { + return res.status(400).json({ success: false, message: 'All fields are required' }); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return res.status(400).json({ success: false, message: 'Invalid email address' }); + } + const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT), + secure: true, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz'; + try { + // Admin notification + await transporter.sendMail({ + from: `"${name}" <${process.env.SMTP_USER}>`, + replyTo: email, + to: contactEmail, + subject: `Portfolio Referral: ${subject}`, + html: ` +
+

+ New Patient Referral +

+
+

Referring Clinician: ${name}

+

Organisation: ${organisation || 'Not specified'}

+

Email: ${email}

+

Subject: ${subject}

+
+
+

Clinical Details:

+

${message}

+
+
+

+ This message was sent from your portfolio contact form. +

+
+ `, + }); + // Auto-reply + await transporter.sendMail({ + from: `"Andy Charlwood" <${process.env.SMTP_USER}>`, + to: email, + subject: 'Thanks for getting in touch!', + html: ` +
+

+ Thanks for your message, ${name}! +

+

+ I've received your referral and will get back to you as soon as possible. +

+
+

Your message:

+

${message}

+
+

+ Best regards,
+ Andy Charlwood
+ Informatics Pharmacist · NHS Norfolk & Waveney ICB +

+
+

+ This is an automated confirmation. Please do not reply to this email. +

+
+ `, + }); + return res.status(200).json({ success: true, message: 'Referral sent successfully!' }); + } + catch (error) { + console.error('Email error:', error); + return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' }); + } +}); +// Chat proxy endpoint — keeps API key server-side +app.post('/api/chat', async (req, res) => { + const apiKey = process.env.OPEN_ROUTER_API_KEY; + if (!apiKey) { + return res.status(500).json({ error: 'LLM API key not configured' }); + } + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'HTTP-Referer': req.headers.origin || req.headers.referer || '', + 'X-Title': 'Andy Charlwood Portfolio', + }, + body: JSON.stringify(req.body), + }); + if (!response.ok) { + return res.status(response.status).json({ error: `LLM API error: ${response.status}` }); + } + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + const reader = response.body?.getReader(); + if (!reader) { + return res.status(500).json({ error: 'No response body' }); + } + const pump = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + res.write(value); + } + res.end(); + }; + await pump(); + } + catch (error) { + console.error('Chat proxy error:', error); + if (!res.headersSent) { + return res.status(500).json({ error: 'Failed to proxy chat request' }); + } + res.end(); + } +}); +// SPA fallback +app.get('*', (_req, res) => { + res.sendFile(path.join(__dirname, 'dist', 'index.html')); +}); +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); +}); diff --git a/server.ts b/server.ts index f905c78..6248a9c 100644 --- a/server.ts +++ b/server.ts @@ -104,6 +104,56 @@ app.post('/api/contact', async (req, res) => { } }) +// Chat proxy endpoint — keeps API key server-side +app.post('/api/chat', async (req, res) => { + const apiKey = process.env.OPEN_ROUTER_API_KEY + if (!apiKey) { + return res.status(500).json({ error: 'LLM API key not configured' }) + } + + try { + const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'HTTP-Referer': req.headers.origin || req.headers.referer || '', + 'X-Title': 'Andy Charlwood Portfolio', + }, + body: JSON.stringify(req.body), + }) + + if (!response.ok) { + return res.status(response.status).json({ error: `LLM API error: ${response.status}` }) + } + + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + + const reader = response.body?.getReader() + if (!reader) { + return res.status(500).json({ error: 'No response body' }) + } + + const pump = async () => { + while (true) { + const { done, value } = await reader.read() + if (done) break + res.write(value) + } + res.end() + } + await pump() + } catch (error) { + console.error('Chat proxy error:', error) + if (!res.headersSent) { + return res.status(500).json({ error: 'Failed to proxy chat request' }) + } + res.end() + } +}) + // SPA fallback app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')) diff --git a/src/lib/llm.ts b/src/lib/llm.ts index be23937..c8f182a 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -8,14 +8,8 @@ export interface ChatMessage { export const LLM_MODEL = 'z-ai/glm-5' export const LLM_DISPLAY_NAME = 'GLM-5' -const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions' - -function getApiKey(): string | undefined { - return import.meta.env.VITE_OPEN_ROUTER_API_KEY as string | undefined -} - export function isLLMAvailable(): boolean { - return !!getApiKey() + return true } export function buildSystemPrompt(): string { @@ -44,22 +38,12 @@ function buildRequestBody( export async function* sendChatMessage( messages: ChatMessage[], ): AsyncGenerator { - const apiKey = getApiKey() - if (!apiKey) { - throw new Error('LLM API key not configured') - } - const systemPrompt = buildSystemPrompt() const body = buildRequestBody(messages, systemPrompt) - const response = await fetch(OPENROUTER_API_URL, { + const response = await fetch('/api/chat', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - 'HTTP-Referer': window.location.origin, - 'X-Title': 'Andy Charlwood Portfolio', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) diff --git a/tsconfig.server.json b/tsconfig.server.json index 3f6f237..f9957fc 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -1,14 +1,13 @@ { "compilerOptions": { "target": "ES2020", - "module": "ESNext", - "moduleResolution": "node", + "module": "NodeNext", + "moduleResolution": "NodeNext", "esModuleInterop": true, "outDir": "dist-server", "rootDir": ".", "strict": true, - "skipLibCheck": true, - "include": ["server.ts"] + "skipLibCheck": true }, "include": ["server.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 0d51f19..4f387b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,4 +9,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + proxy: { + '/api': 'http://localhost:3000', + }, + }, })