137 lines
5.3 KiB
TypeScript
137 lines
5.3 KiB
TypeScript
import { Router } from 'express';
|
|||
|
|
import { env } from '../env.js';
|
||
|
|
import { query, one } from '../db.js';
|
||
|
|
import { ensureBuckets, BUCKETS } from '../lib/storage.js';
|
||
|
|
import { analyzeWebsite } from './analyze-website.js';
|
||
|
|
import { triggerCloudBuild, getCloudBuildStatus } from './cloud-build.js';
|
||
|
|
import type { AuthedRequest } from '../middleware/auth.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Server-side functions (payments, build, email, AI, storage helpers).
|
||
|
|
* Reachable at both POST /api/functions/:name (primary) and
|
||
|
|
* GET|POST /api/functions/v1/:name (alias used by some admin screens).
|
||
|
|
*/
|
||
|
|
const r = Router();
|
||
|
|
|
||
|
|
type Ctx = { body: any; query: any; req: AuthedRequest };
|
||
|
|
type Handler = (ctx: Ctx) => Promise<any>;
|
||
|
|
|
||
|
|
const NOT_CONFIGURED = (gateway: string) =>
|
||
|
|
new Error(`${gateway} is not configured. Add the API keys to your server .env to enable it.`);
|
||
|
|
|
||
|
|
const handlers: Record<string, Handler> = {
|
||
|
|
'analyze-website': async ({ body }) => {
|
||
|
|
const url = body?.websiteUrl || body?.url;
|
||
|
|
if (!url) throw new Error('websiteUrl is required');
|
||
|
|
return analyzeWebsite(url);
|
||
|
|
},
|
||
|
|
|
||
|
|
'cloud-build': async ({ body }) => triggerCloudBuild(body),
|
||
|
|
|
||
|
|
'cloud-build-status': async ({ body }) => {
|
||
|
|
const status = await getCloudBuildStatus(body?.buildId);
|
||
|
|
if (!status) throw new Error('Build not found');
|
||
|
|
return status;
|
||
|
|
},
|
||
|
|
|
||
|
|
'send-email': async ({ body }) => {
|
||
|
|
if (!env.RESEND_API_KEY) {
|
||
|
|
console.log('[send-email] (stub) would send:', body?.to, body?.subject);
|
||
|
|
return { success: true, stubbed: true, message: 'Email logged (Resend not configured)' };
|
||
|
|
}
|
||
|
|
const res = await fetch('https://api.resend.com/emails', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
from: body.from || 'AppForge <onboarding@resend.dev>',
|
||
|
|
to: body.to, subject: body.subject, html: body.html || body.htmlContent,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
const json = await res.json();
|
||
|
|
if (!res.ok) throw new Error(json?.message || 'Email send failed');
|
||
|
|
return { success: true, id: json.id };
|
||
|
|
},
|
||
|
|
|
||
|
|
'test-storage-connection': async () => {
|
||
|
|
ensureBuckets();
|
||
|
|
return { success: true, message: 'Local storage is available', provider: 'local-filesystem' };
|
||
|
|
},
|
||
|
|
|
||
|
|
'storage-admin': async ({ query: q }) => {
|
||
|
|
ensureBuckets();
|
||
|
|
const action = q?.action || 'list';
|
||
|
|
if (action === 'list') {
|
||
|
|
return BUCKETS.map((name) => ({
|
||
|
|
id: name,
|
||
|
|
name,
|
||
|
|
public: name !== 'project-assets',
|
||
|
|
file_size_limit: null,
|
||
|
|
created_at: new Date(0).toISOString(),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
// create/delete are no-ops for fixed local buckets
|
||
|
|
return { success: true, message: `Local storage uses fixed buckets; '${action}' is a no-op.` };
|
||
|
|
},
|
||
|
|
|
||
|
|
'ai-assistant': async ({ body }) => {
|
||
|
|
const messages = body?.messages || (body?.message ? [{ role: 'user', content: body.message }] : []);
|
||
|
|
const apiKey = env.OPENAI_API_KEY;
|
||
|
|
if (!apiKey) {
|
||
|
|
return { reply: 'The AI assistant is not configured. Add OPENAI_API_KEY to the server .env to enable it.', stubbed: true };
|
||
|
|
}
|
||
|
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ model: 'gpt-4o-mini', messages, temperature: 0.7 }),
|
||
|
|
});
|
||
|
|
const json = await res.json();
|
||
|
|
if (!res.ok) throw new Error(json?.error?.message || 'AI request failed');
|
||
|
|
return { reply: json.choices?.[0]?.message?.content || '' };
|
||
|
|
},
|
||
|
|
|
||
|
|
'reset-demo-data': async () => ({ success: true, message: 'Demo data reset not applicable in self-hosted mode' }),
|
||
|
|
|
||
|
|
'retry-webhook': async ({ body }) => {
|
||
|
|
if (!body?.id) throw new Error('Webhook log id required');
|
||
|
|
await query(`UPDATE public.webhook_event_logs SET status = 'retried' WHERE id = $1`, [body.id]);
|
||
|
|
return { success: true };
|
||
|
|
},
|
||
|
|
|
||
|
|
'stripe-checkout': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||
|
|
'stripe-portal': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||
|
|
'stripe-webhook': async () => ({ received: true }),
|
||
|
|
'paypal-checkout': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||
|
|
'paypal-billing': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||
|
|
'paypal-webhook': async () => ({ received: true }),
|
||
|
|
'coinbase-checkout': async () => { throw NOT_CONFIGURED('Coinbase'); },
|
||
|
|
'coinbase-webhook': async () => ({ received: true }),
|
||
|
|
};
|
||
|
|
|
||
|
|
const PUBLIC = new Set(['cloud-build-status', 'test-storage-connection', 'storage-admin',
|
||
|
|
'stripe-webhook', 'paypal-webhook', 'coinbase-webhook']);
|
||
|
|
|
||
|
|
async function dispatch(req: AuthedRequest, res: any) {
|
||
|
|
const name = req.params.name;
|
||
|
|
|
||
|
|
// health probe used by edge-function-health.ts
|
||
|
|
if (req.query?.health) return res.json({ ok: true, function: name });
|
||
|
|
|
||
|
|
const handler = handlers[name];
|
||
|
|
if (!handler) return res.status(404).json({ error: `Unknown function: ${name}` });
|
||
|
|
if (!PUBLIC.has(name) && !req.userId) return res.status(401).json({ error: 'Not authenticated' });
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await handler({ body: req.body || {}, query: req.query || {}, req });
|
||
|
|
res.json(result ?? null);
|
||
|
|
} catch (err: any) {
|
||
|
|
res.status(400).json({ error: err?.message || 'Function failed' });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
r.post('/:name', dispatch);
|
||
|
|
r.get('/:name', dispatch);
|
||
|
|
r.post('/v1/:name', dispatch);
|
||
|
|
r.get('/v1/:name', dispatch);
|
||
|
|
|
||
|
|
export default r;
|