174 lines
8.3 KiB
TypeScript
174 lines
8.3 KiB
TypeScript
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;
|