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:
Leon Serfaty
2026-06-28 04:50:29 -04:00
co-authored by Claude Opus 4.8
commit 1a8b8ee5a0
271 changed files with 63734 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { env } from '../env.js';
import { one } from '../db.js';
const systemPrompt = `You are an expert mobile app designer. Analyze the given website URL and suggest optimal mobile app configuration settings. Respond ONLY with valid JSON (no markdown) with fields: app_name (max 20 chars), primary_color (hex), accent_color (hex), navigation_style (one of bottom-nav, drawer, tabs), features (array of 3-6 of offline_mode, push_notifications, dark_mode, share, favorites, search), app_category (news, shopping, social, business, education, entertainment, lifestyle, utility), description (max 100 chars), icon_style (modern, classic, minimal, rounded, gradient), splash_screen_style (centered-logo, full-bleed, minimal, animated).`;
function domainOf(websiteUrl: string): string {
try {
return new URL(websiteUrl).hostname.replace(/^www\./, '');
} catch {
return websiteUrl;
}
}
function heuristicConfig(websiteUrl: string) {
const domain = domainOf(websiteUrl);
const base = domain.split('.')[0] || 'app';
return {
app_name: base.charAt(0).toUpperCase() + base.slice(1),
primary_color: '#3B82F6',
accent_color: '#8B5CF6',
navigation_style: 'bottom-nav',
features: ['offline_mode', 'push_notifications', 'dark_mode'],
app_category: 'utility',
description: `Mobile app for ${domain}`,
icon_style: 'modern',
splash_screen_style: 'centered-logo',
};
}
async function callOpenAI(apiKey: string, websiteUrl: string): Promise<any | null> {
try {
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: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Analyze this website: ${websiteUrl} (domain: ${domainOf(websiteUrl)}). Return JSON only.` },
],
temperature: 0.7,
max_tokens: 600,
}),
});
if (!res.ok) return null;
const data = await res.json();
let content = (data.choices?.[0]?.message?.content || '').trim();
if (content.startsWith('```json')) content = content.slice(7);
else if (content.startsWith('```')) content = content.slice(3);
if (content.endsWith('```')) content = content.slice(0, -3);
return JSON.parse(content.trim());
} catch {
return null;
}
}
/**
* Returns { config } — AI-powered when OPENAI_API_KEY (or an admin-configured
* AI provider) is available, otherwise a sensible heuristic so the builder
* flow always works.
*/
export async function analyzeWebsite(websiteUrl: string): Promise<{ config: any }> {
// env key first
let apiKey = env.OPENAI_API_KEY;
// admin-configured AI provider (api_configurations) as fallback
if (!apiKey) {
const cfg = await one<{ config: any }>(
`SELECT config FROM public.api_configurations WHERE provider = 'ai' AND is_active = true LIMIT 1`
).catch(() => null);
apiKey = cfg?.config?.openai_api_key || '';
}
if (apiKey) {
const aiConfig = await callOpenAI(apiKey, websiteUrl);
if (aiConfig) return { config: aiConfig };
}
return { config: heuristicConfig(websiteUrl) };
}
+74
View File
@@ -0,0 +1,74 @@
import { query, one } from '../db.js';
import { env } from '../env.js';
interface TriggerArgs {
buildId: string;
websiteUrl: string;
appName: string;
platform: string;
packageName: string;
config: Record<string, unknown>;
}
/**
* Trigger a cloud build.
*
* If CODEMAGIC_API_TOKEN + CODEMAGIC_APP_ID are configured, this calls the
* Codemagic API for real. Otherwise it runs a local SIMULATION so the build
* flow is demonstrable in development (progress + placeholder artifact).
*/
export async function triggerCloudBuild(args: TriggerArgs): Promise<{ cloudBuildId?: string; message: string }> {
if (env.CODEMAGIC_API_TOKEN && env.CODEMAGIC_APP_ID) {
const res = await fetch('https://api.codemagic.io/builds', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-auth-token': env.CODEMAGIC_API_TOKEN },
body: JSON.stringify({
appId: env.CODEMAGIC_APP_ID,
workflowId: args.platform === 'ios' ? 'ios-workflow' : 'android-workflow',
branch: 'main',
environment: {
variables: {
APP_NAME: args.appName,
WEBSITE_URL: args.websiteUrl,
PACKAGE_NAME: args.packageName,
},
},
}),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error || json?.message || 'Codemagic build trigger failed');
const cloudBuildId = json?.buildId || json?._id;
await query(`UPDATE public.app_builds SET status = 'building', progress = 5 WHERE id = $1`, [args.buildId]);
return { cloudBuildId, message: 'Cloud build started (Codemagic)' };
}
// ---- Simulation (no Codemagic configured) ----
await query(`UPDATE public.app_builds SET status = 'building', progress = 10 WHERE id = $1`, [args.buildId]);
simulateBuild(args.buildId).catch((e) => console.error('[cloud-build sim]', e.message));
return { message: 'Cloud build simulated (Codemagic not configured)' };
}
async function simulateBuild(buildId: string) {
const steps = [25, 45, 65, 85];
for (const p of steps) {
await delay(2000);
await query(`UPDATE public.app_builds SET progress = $2 WHERE id = $1 AND status = 'building'`, [buildId, p]);
}
await delay(2000);
const build = await one<any>(`SELECT package_name FROM public.app_builds WHERE id = $1`, [buildId]);
const fileName = `${(build?.package_name || 'app').replace(/\./g, '-')}.apk`;
await query(
`UPDATE public.app_builds
SET status = 'complete', progress = 100,
download_url = $2, file_size_bytes = $3
WHERE id = $1 AND status = 'building'`,
[buildId, `/storage/apk-builds/simulated/${fileName}`, 12 * 1024 * 1024]
);
}
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
/** Returns the current build row (used by the status endpoint). */
export async function getCloudBuildStatus(buildId: string) {
return one(`SELECT * FROM public.app_builds WHERE id = $1`, [buildId]);
}
+136
View File
@@ -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;