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
+173
View File
@@ -0,0 +1,173 @@
import { Router } from 'express';
import { query, one } from '../db.js';
import { insertRow, updateById } from '../lib/sql.js';
import { h } from '../lib/respond.js';
import { requireAuth, requireAdmin, type AuthedRequest } from '../middleware/auth.js';
const r = Router();
// checkAdminStatus is available to any authenticated user
r.get('/check-status', requireAuth, h(async (req: AuthedRequest) => {
const role = await one<{ role: string }>(`SELECT role FROM public.user_roles WHERE user_id = $1 LIMIT 1`, [req.userId]);
return { isAdmin: role?.role === 'admin', role: role?.role || 'user' };
}));
// everything below requires admin
r.use(requireAdmin);
r.get('/stats', h(async () => {
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
const today = todayStart.toISOString();
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString();
const num = async (sql: string, p: any[] = []) =>
parseInt((await one<{ c: string }>(sql, p))?.c || '0', 10);
const [totalUsers, totalBuilds, totalProjects, activeSubscriptions, activeBuilds, newUsersToday, buildsToday] =
await Promise.all([
num(`SELECT count(*)::text c FROM public.profiles`),
num(`SELECT count(*)::text c FROM public.app_builds`),
num(`SELECT count(*)::text c FROM public.app_projects`),
num(`SELECT count(*)::text c FROM public.user_subscriptions WHERE status = 'active'`),
num(`SELECT count(*)::text c FROM public.app_builds WHERE status IN ('pending','building')`),
num(`SELECT count(*)::text c FROM public.profiles WHERE created_at >= $1`, [today]),
num(`SELECT count(*)::text c FROM public.app_builds WHERE created_at >= $1`, [today]),
]);
const mrrRows = await query<any>(
`SELECT s.billing_cycle, p.price_monthly, p.price_yearly
FROM public.user_subscriptions s JOIN public.subscription_plans p ON p.id = s.plan_id
WHERE s.status = 'active'`
);
const mrr = mrrRows.reduce((sum, s) =>
sum + (s.billing_cycle === 'yearly' ? Number(s.price_yearly) / 12 : Number(s.price_monthly)), 0);
const totalRevenue = Number((await one<{ s: string }>(
`SELECT COALESCE(sum(amount),0)::text s FROM public.payment_transactions WHERE status = 'completed'`))?.s || 0);
const monthlyRevenue = Number((await one<{ s: string }>(
`SELECT COALESCE(sum(amount),0)::text s FROM public.payment_transactions WHERE status = 'completed' AND created_at >= $1`,
[monthStart]))?.s || 0);
return {
totalUsers, totalBuilds, totalProjects, totalRevenue,
monthlyRevenue: monthlyRevenue || mrr, activeBuilds, activeSubscriptions,
mrr, revenue: totalRevenue, newUsersToday, buildsToday,
};
}));
r.get('/users', h(async () => {
const profiles = await query<any>(`SELECT * FROM public.profiles ORDER BY created_at DESC LIMIT 100`);
if (!profiles.length) return [];
const ids = profiles.map((p) => p.id);
const roles = await query<any>(`SELECT user_id, role FROM public.user_roles WHERE user_id = ANY($1::text[])`, [ids]);
const credits = await query<any>(`SELECT user_id, monthly_credits, bonus_credits FROM public.user_credits WHERE user_id = ANY($1::text[])`, [ids]);
const roleMap = new Map<string, any[]>();
roles.forEach((r2) => { const a = roleMap.get(r2.user_id) || []; a.push({ role: r2.role }); roleMap.set(r2.user_id, a); });
const credMap = new Map(credits.map((c) => [c.user_id, c]));
return profiles.map((p) => ({
...p,
user_roles: roleMap.get(p.id) || [],
user_credits: credMap.get(p.id) ? [credMap.get(p.id)] : [],
}));
}));
r.patch('/users/:userId/role', h(async (req) => {
const { userId } = req.params;
const role = req.body?.role;
if (role === null) {
await query(`DELETE FROM public.user_roles WHERE user_id = $1`, [userId]);
return { message: 'Role removed' };
}
const existing = await one(`SELECT id FROM public.user_roles WHERE user_id = $1 LIMIT 1`, [userId]);
if (existing) {
await query(`UPDATE public.user_roles SET role = $2 WHERE user_id = $1`, [userId, role]);
return { message: 'Role updated' };
}
await query(`INSERT INTO public.user_roles (user_id, role) VALUES ($1, $2)`, [userId, role]);
return { message: 'Role created' };
}));
r.get('/transactions', h(async () =>
query(`SELECT * FROM public.payment_transactions ORDER BY created_at DESC`)));
r.get('/builds', h(async () =>
query(`SELECT * FROM public.app_builds ORDER BY created_at DESC LIMIT 100`)));
// plans
r.get('/plans', h(async () =>
query(`SELECT * FROM public.subscription_plans ORDER BY price_monthly ASC`)));
r.post('/plans', h(async (req) => insertRow('subscription_plans', req.body)));
r.patch('/plans/:id', h(async (req) => updateById('subscription_plans', req.params.id, req.body)));
// credit packs
r.get('/credit-packs', h(async () =>
query(`SELECT * FROM public.credit_packs ORDER BY price ASC`)));
r.post('/credit-packs', h(async (req) => insertRow('credit_packs', req.body)));
r.patch('/credit-packs/:id', h(async (req) => updateById('credit_packs', req.params.id, req.body)));
r.delete('/credit-packs/:id', h(async (req) => {
await query(`DELETE FROM public.credit_packs WHERE id = $1`, [req.params.id]);
return { message: 'Credit pack deleted' };
}));
// system settings
r.get('/settings', h(async () => query(`SELECT * FROM public.system_settings ORDER BY key`)));
r.patch('/settings/:id', h(async (req) => updateById('system_settings', req.params.id, { value: req.body?.value })));
r.put('/settings', h(async (req) => {
const { key, value, category = 'general', description = null } = req.body || {};
return one(
`INSERT INTO public.system_settings (key, value, category, description)
VALUES ($1, $2::jsonb, $3, $4)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, category = EXCLUDED.category, description = EXCLUDED.description, updated_at = now()
RETURNING *`,
[key, JSON.stringify(value ?? null), category, description]
);
}));
// email templates
r.get('/email-templates', h(async () => query(`SELECT * FROM public.email_templates ORDER BY name`)));
r.patch('/email-templates/:id', h(async (req) => updateById('email_templates', req.params.id, req.body)));
// plugins
r.get('/plugins', h(async () => query(`SELECT * FROM public.plugins ORDER BY name`)));
r.patch('/plugins/:id', h(async (req) => updateById('plugins', req.params.id, req.body)));
// api configs
r.get('/api-configs', h(async () => query(`SELECT * FROM public.api_configurations ORDER BY name`)));
r.post('/api-configs', h(async (req) => insertRow('api_configurations', req.body)));
r.patch('/api-configs/:id', h(async (req) => updateById('api_configurations', req.params.id, req.body)));
r.delete('/api-configs/:id', h(async (req) => {
await query(`DELETE FROM public.api_configurations WHERE id = $1`, [req.params.id]);
return { message: 'API config deleted' };
}));
// invoices
r.get('/invoices', h(async () => query(`SELECT * FROM public.invoices ORDER BY created_at DESC`)));
r.post('/invoices', h(async (req) => insertRow('invoices', req.body)));
r.patch('/invoices/:id', h(async (req) => updateById('invoices', req.params.id, req.body)));
// audit log
r.get('/settings-audit-log', h(async () =>
query(`SELECT * FROM public.settings_audit_log ORDER BY created_at DESC LIMIT 100`)));
// payment gateway configs (used by admin payment config screens)
r.get('/payment-gateways', h(async () => query(`SELECT * FROM public.payment_gateway_configs ORDER BY gateway`)));
r.put('/payment-gateways/:gateway', h(async (req) => {
const { gateway } = req.params;
const { is_enabled, is_test_mode, sandbox_config, live_config } = req.body || {};
return one(
`INSERT INTO public.payment_gateway_configs (gateway, is_enabled, is_test_mode, sandbox_config, live_config)
VALUES ($1,$2,$3,$4::jsonb,$5::jsonb)
ON CONFLICT (gateway) DO UPDATE SET
is_enabled = COALESCE(EXCLUDED.is_enabled, payment_gateway_configs.is_enabled),
is_test_mode = COALESCE(EXCLUDED.is_test_mode, payment_gateway_configs.is_test_mode),
sandbox_config = EXCLUDED.sandbox_config, live_config = EXCLUDED.live_config, updated_at = now()
RETURNING *`,
[gateway, is_enabled ?? null, is_test_mode ?? null, JSON.stringify(sandbox_config ?? {}), JSON.stringify(live_config ?? {})]
);
}));
// webhook event logs
r.get('/webhook-logs', h(async () =>
query(`SELECT * FROM public.webhook_event_logs ORDER BY created_at DESC LIMIT 200`)));
export default r;