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,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) };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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