AppForge: migrate off Supabase to external Postgres + Better Auth + local storage
- New Express + TypeScript backend (server/) with pg, Better Auth, local file storage - De-Supabased Postgres schema (server/db) and TS reimplementations of DB functions - Frontend data layer rewired to REST (rest-client + backend-client compat shim) - Removed all Supabase references (code, config, deps, docs) - New brand assets: gradient favicon/app icons + dark/white wordmark logos Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user