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,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;
|
||||
Reference in New Issue
Block a user