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
+52
View File
@@ -0,0 +1,52 @@
-- Better Auth core schema (v1.x). Identifiers are camelCase and quoted to
-- match Better Auth's default field->column mapping.
CREATE TABLE IF NOT EXISTS "user" (
"id" text PRIMARY KEY,
"name" text NOT NULL DEFAULT '',
"email" text NOT NULL UNIQUE,
"emailVerified" boolean NOT NULL DEFAULT false,
"image" text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "session" (
"id" text PRIMARY KEY,
"expiresAt" timestamptz NOT NULL,
"token" text NOT NULL UNIQUE,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now(),
"ipAddress" text,
"userAgent" text,
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "account" (
"id" text PRIMARY KEY,
"accountId" text NOT NULL,
"providerId" text NOT NULL,
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"accessToken" text,
"refreshToken" text,
"idToken" text,
"accessTokenExpiresAt" timestamptz,
"refreshTokenExpiresAt" timestamptz,
"scope" text,
"password" text,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "verification" (
"id" text PRIMARY KEY,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expiresAt" timestamptz NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_session_user ON "session" ("userId");
CREATE INDEX IF NOT EXISTS idx_account_user ON "account" ("userId");
CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification" ("identifier");
+413
View File
@@ -0,0 +1,413 @@
-- ============================================================
-- AppForge — application schema (de-Supabased)
-- Target: plain PostgreSQL. No RLS, no auth.* schema.
-- User identity is owned by Better Auth ("user" table, text id).
-- All user references are TEXT referencing "user"(id).
-- This file is idempotent (safe to re-run).
-- ============================================================
-- gen_random_uuid() is built into PG13+ core; pgcrypto kept for safety.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ---------- Enums ----------
DO $$ BEGIN
CREATE TYPE public.app_role AS ENUM ('admin', 'moderator', 'user');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE public.payment_method AS ENUM ('paypal', 'crypto', 'bank_transfer', 'stripe');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE public.subscription_tier AS ENUM ('free', 'pro', 'enterprise');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE public.transaction_status AS ENUM ('pending', 'completed', 'failed', 'refunded');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ---------- updated_at trigger fn ----------
CREATE OR REPLACE FUNCTION public.update_updated_at_column() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
-- ============================================================
-- Tables
-- ============================================================
CREATE TABLE IF NOT EXISTS public.profiles (
id text PRIMARY KEY,
email text,
display_name text,
avatar_url text,
company_name text,
stripe_customer_id text UNIQUE,
marketing_consent boolean DEFAULT false,
marketing_consent_date timestamptz,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.user_roles (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
role public.app_role NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
CONSTRAINT user_roles_user_id_role_key UNIQUE (user_id, role)
);
CREATE TABLE IF NOT EXISTS public.subscription_plans (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
tier public.subscription_tier NOT NULL UNIQUE,
name text NOT NULL,
description text,
price_monthly numeric(10,2) DEFAULT 0 NOT NULL,
price_yearly numeric(10,2) DEFAULT 0 NOT NULL,
monthly_credits integer DEFAULT 0 NOT NULL,
features jsonb DEFAULT '[]'::jsonb NOT NULL,
is_active boolean DEFAULT true NOT NULL,
stripe_price_id text,
stripe_yearly_price_id text,
paypal_plan_id text,
paypal_yearly_plan_id text,
paypal_product_id text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.user_credits (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL UNIQUE,
monthly_credits integer DEFAULT 0 NOT NULL,
bonus_credits integer DEFAULT 0 NOT NULL,
credits_reset_at timestamptz,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.user_subscriptions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL UNIQUE,
plan_id uuid NOT NULL REFERENCES public.subscription_plans(id),
status text DEFAULT 'active'::text NOT NULL,
billing_cycle text DEFAULT 'monthly'::text NOT NULL,
current_period_start timestamptz DEFAULT now() NOT NULL,
current_period_end timestamptz NOT NULL,
cancel_at_period_end boolean DEFAULT false NOT NULL,
payment_method public.payment_method,
external_subscription_id text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.app_projects (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
website_url text NOT NULL,
app_name text NOT NULL,
primary_color text DEFAULT '#22D3EE'::text,
accent_color text DEFAULT '#A855F7'::text,
navigation_style text DEFAULT 'bottom-nav'::text,
features text[] DEFAULT '{}'::text[],
app_category text,
description text,
icon_style text DEFAULT 'modern'::text,
splash_screen_style text DEFAULT 'centered-logo'::text,
build_status text DEFAULT 'draft'::text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
CONSTRAINT app_projects_user_id_website_url_key UNIQUE (user_id, website_url)
);
CREATE TABLE IF NOT EXISTS public.app_builds (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
project_id uuid REFERENCES public.app_projects(id) ON DELETE SET NULL,
website_url text NOT NULL,
app_name text NOT NULL,
package_name text NOT NULL,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
progress integer DEFAULT 0 NOT NULL,
download_url text,
file_size_bytes bigint,
error_message text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
CONSTRAINT app_builds_status_check CHECK (status = ANY (ARRAY['pending'::text,'building'::text,'complete'::text,'failed'::text]))
);
CREATE TABLE IF NOT EXISTS public.app_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
name text NOT NULL,
description text,
config jsonb NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.automation_configs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
project_id uuid NOT NULL REFERENCES public.app_projects(id) ON DELETE CASCADE,
user_id text NOT NULL,
workflow_type text NOT NULL,
is_enabled boolean DEFAULT true NOT NULL,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
last_run_at timestamptz,
next_run_at timestamptz,
run_count integer DEFAULT 0 NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
CONSTRAINT automation_configs_project_id_workflow_type_key UNIQUE (project_id, workflow_type)
);
CREATE TABLE IF NOT EXISTS public.automation_logs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
automation_id uuid NOT NULL REFERENCES public.automation_configs(id) ON DELETE CASCADE,
user_id text NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
message text,
metadata jsonb DEFAULT '{}'::jsonb,
started_at timestamptz DEFAULT now() NOT NULL,
completed_at timestamptz,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.credit_packs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
credits integer NOT NULL,
price numeric(10,2) NOT NULL,
description text,
is_active boolean DEFAULT true NOT NULL,
stripe_price_id text,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.credit_usage_history (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
amount integer NOT NULL,
action_type text NOT NULL,
description text,
metadata jsonb DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.payment_transactions (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
amount numeric(10,2) NOT NULL,
currency text DEFAULT 'USD'::text NOT NULL,
payment_method public.payment_method NOT NULL,
status public.transaction_status DEFAULT 'pending'::public.transaction_status NOT NULL,
transaction_type text NOT NULL,
reference_id text,
external_transaction_id text,
paypal_order_id text,
paypal_subscription_id text,
metadata jsonb DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.bank_transfer_requests (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
transaction_id uuid REFERENCES public.payment_transactions(id),
amount numeric(10,2) NOT NULL,
currency text DEFAULT 'USD'::text NOT NULL,
plan_id uuid REFERENCES public.subscription_plans(id),
credit_pack_id uuid REFERENCES public.credit_packs(id),
status text DEFAULT 'pending'::text NOT NULL,
proof_of_payment_url text,
admin_notes text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.chat_messages (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text NOT NULL,
role text NOT NULL,
content text NOT NULL,
project_id uuid REFERENCES public.app_projects(id) ON DELETE CASCADE,
created_at timestamptz DEFAULT now() NOT NULL,
CONSTRAINT chat_messages_role_check CHECK (role = ANY (ARRAY['user'::text,'assistant'::text]))
);
CREATE TABLE IF NOT EXISTS public.consent_records (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id text,
email text,
consent_type text NOT NULL,
consented boolean DEFAULT false NOT NULL,
ip_address text,
user_agent text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.email_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL UNIQUE,
subject text NOT NULL,
html_content text NOT NULL,
text_content text,
variables jsonb DEFAULT '[]'::jsonb,
is_active boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.invoices (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
invoice_number text NOT NULL UNIQUE,
user_id text NOT NULL,
amount numeric NOT NULL,
currency text DEFAULT 'USD'::text NOT NULL,
status text DEFAULT 'draft'::text NOT NULL,
due_date timestamptz,
paid_at timestamptz,
items jsonb DEFAULT '[]'::jsonb NOT NULL,
notes text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.api_configurations (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
provider text NOT NULL,
api_key_masked text,
is_active boolean DEFAULT false NOT NULL,
rate_limit integer DEFAULT 1000,
usage_count integer DEFAULT 0,
last_used_at timestamptz,
config jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.plugins (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
slug text NOT NULL UNIQUE,
type text NOT NULL,
description text,
config jsonb DEFAULT '{}'::jsonb,
is_active boolean DEFAULT false NOT NULL,
version text DEFAULT '1.0.0'::text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.system_settings (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
key text NOT NULL UNIQUE,
value jsonb DEFAULT '{}'::jsonb NOT NULL,
category text DEFAULT 'general'::text NOT NULL,
description text,
updated_at timestamptz DEFAULT now() NOT NULL,
updated_by text
);
CREATE TABLE IF NOT EXISTS public.settings_audit_log (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
setting_id uuid REFERENCES public.system_settings(id) ON DELETE SET NULL,
setting_key text NOT NULL,
old_value jsonb,
new_value jsonb NOT NULL,
changed_by text,
changed_by_email text,
change_type text DEFAULT 'update'::text NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.payment_gateway_configs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
gateway text NOT NULL UNIQUE,
is_enabled boolean DEFAULT false,
is_test_mode boolean DEFAULT true,
sandbox_config jsonb DEFAULT '{}'::jsonb,
live_config jsonb DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.webhook_event_logs (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
gateway text NOT NULL,
event_type text NOT NULL,
event_id text,
status text NOT NULL DEFAULT 'received',
payload jsonb DEFAULT '{}'::jsonb,
response_status integer,
error_message text,
processing_time_ms integer,
created_at timestamptz DEFAULT now() NOT NULL
);
-- ---------- Indexes ----------
CREATE INDEX IF NOT EXISTS idx_automation_configs_project ON public.automation_configs (project_id);
CREATE INDEX IF NOT EXISTS idx_automation_configs_type ON public.automation_configs (workflow_type);
CREATE INDEX IF NOT EXISTS idx_automation_configs_user ON public.automation_configs (user_id);
CREATE INDEX IF NOT EXISTS idx_automation_logs_automation ON public.automation_logs (automation_id);
CREATE INDEX IF NOT EXISTS idx_automation_logs_status ON public.automation_logs (status);
CREATE INDEX IF NOT EXISTS idx_chat_messages_user_project ON public.chat_messages (user_id, project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_consent_records_email ON public.consent_records (email);
CREATE INDEX IF NOT EXISTS idx_consent_records_user_id ON public.consent_records (user_id);
CREATE INDEX IF NOT EXISTS idx_credit_usage_action ON public.credit_usage_history (action_type);
CREATE INDEX IF NOT EXISTS idx_credit_usage_user_date ON public.credit_usage_history (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_builds_user ON public.app_builds (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_projects_user ON public.app_projects (user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settings_audit_log_created_at ON public.settings_audit_log (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_settings_audit_log_setting_id ON public.settings_audit_log (setting_id);
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_gateway ON public.webhook_event_logs (gateway);
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_created_at ON public.webhook_event_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_webhook_event_logs_status ON public.webhook_event_logs (status);
-- ---------- updated_at triggers ----------
DO $$
DECLARE t text;
BEGIN
FOR t IN
SELECT unnest(ARRAY[
'api_configurations','app_builds','app_projects','app_templates','automation_configs',
'bank_transfer_requests','consent_records','email_templates','invoices','payment_transactions',
'plugins','profiles','subscription_plans','system_settings','user_credits','user_subscriptions',
'payment_gateway_configs'
])
LOOP
EXECUTE format('DROP TRIGGER IF EXISTS update_%1$s_updated_at ON public.%1$s;', t);
EXECUTE format('CREATE TRIGGER update_%1$s_updated_at BEFORE UPDATE ON public.%1$s FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();', t);
END LOOP;
END $$;
-- ============================================================
-- Seed data
-- ============================================================
INSERT INTO public.subscription_plans (tier, name, description, price_monthly, price_yearly, monthly_credits, features, is_active)
VALUES
('free', 'Free', 'Get started building apps', 0, 0, 5,
'["5 credits / month","Community support","Standard build queue"]'::jsonb, true),
('pro', 'Pro', 'For serious builders', 19, 190, 100,
'["100 credits / month","Priority builds","Email support","Custom branding"]'::jsonb, true),
('enterprise', 'Enterprise', 'For teams and agencies', 99, 990, 1000,
'["1000 credits / month","Fastest builds","Dedicated support","Team seats","White-label"]'::jsonb, true)
ON CONFLICT (tier) DO NOTHING;
INSERT INTO public.system_settings (key, value, category, description)
VALUES
('credits_per_build', '1'::jsonb, 'builds', 'Number of credits consumed per app build'),
('default_signup_credits', '5'::jsonb, 'app', 'Number of free credits given to new users on signup')
ON CONFLICT (key) DO NOTHING;
INSERT INTO public.payment_gateway_configs (gateway, is_enabled, is_test_mode)
VALUES ('paypal', false, true), ('stripe', false, true), ('coinbase', false, true), ('bank_transfer', false, false)
ON CONFLICT (gateway) DO NOTHING;
+68
View File
@@ -0,0 +1,68 @@
import express from 'express';
import cors from 'cors';
import { toNodeHandler } from 'better-auth/node';
import { env } from './env.js';
import { auth } from './auth.js';
import { withSession } from './middleware/auth.js';
import { ensureBuckets } from './lib/storage.js';
import userRoutes from './routes/user.js';
import projectRoutes from './routes/projects.js';
import buildRoutes from './routes/builds.js';
import bankTransferRoutes from './routes/bankTransfer.js';
import adminRoutes from './routes/admin.js';
import storageRoutes from './routes/storage.js';
import dbRoutes from './routes/db.js';
import functionRoutes from './functions/index.js';
import {
automationRouter, templatesRouter, plansRouter,
creditPacksRouter, chatRouter, setupRouter,
} from './routes/misc.js';
export function createApp() {
const app = express();
app.use(
cors({
origin: [env.BETTER_AUTH_URL, 'http://localhost:8080', 'http://localhost:8787'],
credentials: true,
})
);
// Better Auth must be mounted BEFORE express.json() (it reads the raw body).
app.all('/api/auth/*', toNodeHandler(auth));
// JSON body parsing for the rest of the API
app.use(express.json({ limit: '2mb' }));
// Attach session (if present) to every /api request
app.use('/api', withSession);
// Serve local file storage
ensureBuckets();
app.use(env.STORAGE_PUBLIC_PREFIX, express.static(env.STORAGE_DIR));
// Health
app.get('/api/health', (_req, res) => res.json({ ok: true }));
// API routes
app.use('/api/user', userRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/builds', buildRoutes);
app.use('/api/automation', automationRouter);
app.use('/api/templates', templatesRouter);
app.use('/api/plans', plansRouter);
app.use('/api/credit-packs', creditPacksRouter);
app.use('/api/chat', chatRouter);
app.use('/api/bank-transfers', bankTransferRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/storage', storageRoutes);
app.use('/api/db', dbRoutes);
app.use('/api/functions', functionRoutes);
app.use('/api/setup', setupRouter);
// Fallback for unknown API routes
app.use('/api', (_req, res) => res.status(404).json({ error: 'Not found' }));
return app;
}
+110
View File
@@ -0,0 +1,110 @@
import { betterAuth } from 'better-auth';
import { pool, one, query } from './db.js';
import { env } from './env.js';
/**
* Better Auth instance — email/password (+ optional Google), backed by the
* external Postgres pool.
*
* On user creation we bootstrap the app-specific rows (profiles, user_credits,
* free subscription, first-admin role) via a database hook.
*/
export const auth = betterAuth({
database: pool,
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: [
env.BETTER_AUTH_URL,
'http://localhost:8080',
'http://localhost:8787',
],
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
minPasswordLength: 6,
autoSignIn: true,
},
socialProviders:
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: undefined,
user: {
additionalFields: {},
},
databaseHooks: {
user: {
create: {
after: async (user: any) => {
try {
await bootstrapNewUser(user.id, user.email, user.name);
} catch (e) {
console.error('[auth] bootstrapNewUser failed:', (e as Error).message);
}
},
},
},
},
});
async function bootstrapNewUser(userId: string, email: string, name?: string | null) {
const displayName = name || (email ? email.split('@')[0] : 'User');
// profile
await query(
`INSERT INTO public.profiles (id, email, display_name)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email`,
[userId, email, displayName]
);
// default signup credits from system_settings (fallback 5)
const setting = await one<{ value: any }>(
`SELECT value FROM public.system_settings WHERE key = 'default_signup_credits' LIMIT 1`
);
let defaultCredits = 5;
if (setting && setting.value != null) {
const parsed = typeof setting.value === 'number' ? setting.value : parseInt(String(setting.value), 10);
if (!Number.isNaN(parsed)) defaultCredits = parsed;
}
await query(
`INSERT INTO public.user_credits (user_id, monthly_credits, bonus_credits, credits_reset_at)
VALUES ($1, $2, 0, now() + interval '1 month')
ON CONFLICT (user_id) DO NOTHING`,
[userId, defaultCredits]
);
// free subscription
const freePlan = await one<{ id: string }>(
`SELECT id FROM public.subscription_plans WHERE tier = 'free' LIMIT 1`
);
if (freePlan) {
await query(
`INSERT INTO public.user_subscriptions (user_id, plan_id, current_period_end)
VALUES ($1, $2, now() + interval '1 month')
ON CONFLICT (user_id) DO NOTHING`,
[userId, freePlan.id]
);
}
// first-admin assignment
const adminCount = await one<{ count: string }>(
`SELECT count(*)::text AS count FROM public.user_roles WHERE role = 'admin'`
);
const noAdmins = !adminCount || adminCount.count === '0';
const isInitialAdmin =
env.INITIAL_ADMIN_EMAIL && email && email.toLowerCase() === env.INITIAL_ADMIN_EMAIL;
if (isInitialAdmin && noAdmins) {
await query(
`INSERT INTO public.user_roles (user_id, role) VALUES ($1, 'admin')
ON CONFLICT (user_id, role) DO NOTHING`,
[userId]
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import pg from 'pg';
import { env } from './env.js';
const { Pool } = pg;
// Strip libpq-style sslmode from the URL so it doesn't force certificate
// verification; we control TLS via the `ssl` option below instead.
function stripSslMode(url: string): string {
return url.replace(/([?&])sslmode=[^&]*/i, '$1').replace(/[?&]$/, '');
}
export const pool = new Pool({
connectionString: stripSslMode(env.DATABASE_URL),
ssl: env.PGSSL_NO_VERIFY ? { rejectUnauthorized: false } : undefined,
max: 10,
});
pool.on('error', (err) => {
console.error('[pg] unexpected pool error:', err.message);
});
/** Run a query and return rows. */
export async function query<T = any>(text: string, params: any[] = []): Promise<T[]> {
const res = await pool.query(text, params);
return res.rows as T[];
}
/** Run a query and return the first row (or null). */
export async function one<T = any>(text: string, params: any[] = []): Promise<T | null> {
const rows = await query<T>(text, params);
return rows.length ? rows[0] : null;
}
+45
View File
@@ -0,0 +1,45 @@
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
// Load .env from the project root (one level above /server)
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '../../');
dotenv.config({ path: path.join(ROOT, '.env') });
export const ROOT_DIR = ROOT;
export const env = {
DATABASE_URL: process.env.DATABASE_URL || '',
PGSSL_NO_VERIFY: process.env.PGSSL_NO_VERIFY === 'true',
PORT: parseInt(process.env.PORT || '8787', 10),
NODE_ENV: process.env.NODE_ENV || 'development',
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET || 'dev-insecure-secret',
BETTER_AUTH_URL: process.env.BETTER_AUTH_URL || 'http://localhost:8080',
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || '',
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET || '',
STORAGE_DIR: path.isAbsolute(process.env.STORAGE_DIR || '')
? (process.env.STORAGE_DIR as string)
: path.join(ROOT, process.env.STORAGE_DIR || 'server/storage'),
STORAGE_PUBLIC_PREFIX: process.env.STORAGE_PUBLIC_PREFIX || '/storage',
INITIAL_ADMIN_EMAIL: (process.env.INITIAL_ADMIN_EMAIL || '').toLowerCase().trim(),
// Optional integration secrets
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || '',
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET || '',
PAYPAL_CLIENT_ID: process.env.PAYPAL_CLIENT_ID || '',
PAYPAL_CLIENT_SECRET: process.env.PAYPAL_CLIENT_SECRET || '',
COINBASE_API_KEY: process.env.COINBASE_API_KEY || '',
CODEMAGIC_API_TOKEN: process.env.CODEMAGIC_API_TOKEN || '',
CODEMAGIC_APP_ID: process.env.CODEMAGIC_APP_ID || '',
RESEND_API_KEY: process.env.RESEND_API_KEY || '',
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
};
if (!env.DATABASE_URL) {
console.error('[env] DATABASE_URL is not set. The server cannot start.');
}
+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;
+22
View File
@@ -0,0 +1,22 @@
import './env.js';
import { env } from './env.js';
import { createApp } from './app.js';
import { pool } from './db.js';
async function main() {
// verify DB connectivity early
try {
await pool.query('SELECT 1');
console.log('[server] connected to Postgres');
} catch (e) {
console.error('[server] FAILED to connect to Postgres:', (e as Error).message);
}
const app = createApp();
app.listen(env.PORT, () => {
console.log(`[server] AppForge API listening on http://localhost:${env.PORT}`);
console.log(`[server] storage dir: ${env.STORAGE_DIR}`);
});
}
main();
+96
View File
@@ -0,0 +1,96 @@
import { pool } from '../db.js';
/**
* Reimplements public.use_credits(uuid, integer, text, text).
* Deducts from bonus first, then monthly, atomically, and logs usage.
* Returns true on success, false if insufficient credits / no record.
*/
export async function useCredits(
userId: string,
amount: number,
actionType = 'app_build',
description: string | null = null
): Promise<boolean> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const cur = await client.query(
`SELECT monthly_credits, bonus_credits FROM public.user_credits WHERE user_id = $1 FOR UPDATE`,
[userId]
);
if (cur.rowCount === 0) {
await client.query('ROLLBACK');
return false;
}
const monthly = cur.rows[0].monthly_credits as number;
const bonus = cur.rows[0].bonus_credits as number;
if (monthly + bonus < amount) {
await client.query('ROLLBACK');
return false;
}
let remaining = amount;
if (bonus >= remaining) {
await client.query(
`UPDATE public.user_credits SET bonus_credits = bonus_credits - $2, updated_at = now() WHERE user_id = $1`,
[userId, remaining]
);
} else {
remaining -= bonus;
await client.query(
`UPDATE public.user_credits SET bonus_credits = 0, monthly_credits = monthly_credits - $2, updated_at = now() WHERE user_id = $1`,
[userId, remaining]
);
}
await client.query(
`INSERT INTO public.credit_usage_history (user_id, amount, action_type, description) VALUES ($1, $2, $3, $4)`,
[userId, amount, actionType, description]
);
await client.query('COMMIT');
return true;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
/**
* Reimplements public.add_credits(uuid, integer, text, text, text).
* credit_type 'monthly' adds to monthly_credits, otherwise bonus_credits.
*/
export async function addCredits(
userId: string,
amount: number,
creditType = 'bonus',
actionType = 'purchase',
description: string | null = null
): Promise<boolean> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const column = creditType === 'monthly' ? 'monthly_credits' : 'bonus_credits';
const res = await client.query(
`UPDATE public.user_credits SET ${column} = ${column} + $2, updated_at = now() WHERE user_id = $1`,
[userId, amount]
);
if (res.rowCount === 0) {
await client.query('ROLLBACK');
return false;
}
// negative amount indicates credit added (matches original convention)
await client.query(
`INSERT INTO public.credit_usage_history (user_id, amount, action_type, description) VALUES ($1, $2, $3, $4)`,
[userId, -amount, actionType, description]
);
await client.query('COMMIT');
return true;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Request, Response, NextFunction } from 'express';
/**
* Wrap an async route handler so it returns its resolved value as the JSON body
* and converts thrown errors into a 400 `{ error }` shape that the frontend
* `{ data, error }` client understands.
*
* Return a plain value → 200 { ...value }.
* Throw an Error → 400 { error: message }.
*/
export function h(
fn: (req: Request, res: Response) => Promise<any>
) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await fn(req, res);
if (res.headersSent) return;
res.json(result ?? null);
} catch (err: any) {
if (res.headersSent) return next(err);
const status = err?.status || 400;
res.status(status).json({ error: err?.message || 'Request failed' });
}
};
}
export class HttpError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { one } from '../db.js';
export type AppRole = 'admin' | 'moderator' | 'user';
/** Reimplements public.has_role(uuid, app_role). */
export async function hasRole(userId: string, role: AppRole): Promise<boolean> {
const row = await one<{ exists: boolean }>(
`SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = $1 AND role = $2) AS exists`,
[userId, role]
);
return !!row?.exists;
}
/** Reimplements public.get_user_role(uuid). Returns null if none. */
export async function getUserRole(userId: string): Promise<AppRole | null> {
const row = await one<{ role: AppRole }>(
`SELECT role FROM public.user_roles WHERE user_id = $1 LIMIT 1`,
[userId]
);
return row?.role ?? null;
}
/** Reimplements public.no_admin_exists(). */
export async function noAdminExists(): Promise<boolean> {
const row = await one<{ exists: boolean }>(
`SELECT NOT EXISTS (SELECT 1 FROM public.user_roles WHERE role = 'admin') AS exists`
);
return !!row?.exists;
}
+49
View File
@@ -0,0 +1,49 @@
import { one } from '../db.js';
const JSON_COLUMNS = new Set([
'config', 'features', 'variables', 'value', 'metadata', 'items',
'sandbox_config', 'live_config', 'payload', 'old_value', 'new_value',
]);
function encode(col: string, val: any): any {
if (val !== null && typeof val === 'object' && JSON_COLUMNS.has(col) && !Array.isArray(val)) {
return JSON.stringify(val);
}
// jsonb scalar columns (value) may receive primitives — let pg handle arrays via text[]
if (JSON_COLUMNS.has(col) && (typeof val === 'number' || typeof val === 'boolean')) {
return JSON.stringify(val);
}
return val;
}
/** Insert a row from a plain object and return the inserted row. */
export async function insertRow<T = any>(table: string, obj: Record<string, any>): Promise<T> {
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
const cols = keys.map((k) => `"${k}"`).join(', ');
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const params = keys.map((k) => encode(k, obj[k]));
const row = await one<T>(
`INSERT INTO public.${table} (${cols}) VALUES (${placeholders}) RETURNING *`,
params
);
return row as T;
}
/** Update a row by id from a plain object and return the updated row. */
export async function updateById<T = any>(
table: string,
id: string,
updates: Record<string, any>,
idCol = 'id'
): Promise<T | null> {
const keys = Object.keys(updates).filter((k) => updates[k] !== undefined && k !== idCol);
if (keys.length === 0) {
return one<T>(`SELECT * FROM public.${table} WHERE "${idCol}" = $1`, [id]);
}
const sets = keys.map((k, i) => `"${k}" = $${i + 2}`).join(', ');
const params = [id, ...keys.map((k) => encode(k, updates[k]))];
return one<T>(
`UPDATE public.${table} SET ${sets} WHERE "${idCol}" = $1 RETURNING *`,
params
);
}
+52
View File
@@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import { env } from '../env.js';
export const BUCKETS = ['avatars', 'app-icons', 'splash-screens', 'apk-builds', 'project-assets'];
function safeJoin(base: string, target: string): string {
const resolved = path.resolve(base, target);
if (!resolved.startsWith(path.resolve(base))) {
throw new Error('Invalid path');
}
return resolved;
}
export function bucketDir(bucket: string): string {
if (!/^[a-z0-9-]+$/.test(bucket)) throw new Error('Invalid bucket name');
return path.join(env.STORAGE_DIR, bucket);
}
export function ensureBuckets() {
for (const b of BUCKETS) {
fs.mkdirSync(path.join(env.STORAGE_DIR, b), { recursive: true });
}
}
export function writeFile(bucket: string, relPath: string, buffer: Buffer): { path: string; url: string } {
const dir = bucketDir(bucket);
const dest = safeJoin(dir, relPath);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buffer);
return { path: relPath, url: publicUrl(bucket, relPath) };
}
export function deleteFile(bucket: string, relPath: string) {
const dir = bucketDir(bucket);
const dest = safeJoin(dir, relPath);
if (fs.existsSync(dest)) fs.unlinkSync(dest);
}
export function listFiles(bucket: string, folder = ''): { name: string; id: string }[] {
const dir = bucketDir(bucket);
const target = safeJoin(dir, folder);
if (!fs.existsSync(target)) return [];
return fs.readdirSync(target, { withFileTypes: true }).map((e) => ({
name: e.name,
id: e.isDirectory() ? `${e.name}/` : e.name,
}));
}
export function publicUrl(bucket: string, relPath: string): string {
return `${env.STORAGE_PUBLIC_PREFIX}/${bucket}/${relPath}`.replace(/\/+/g, '/');
}
+39
View File
@@ -0,0 +1,39 @@
import type { Request, Response, NextFunction } from 'express';
import { fromNodeHeaders } from 'better-auth/node';
import { auth } from '../auth.js';
import { hasRole } from '../lib/roles.js';
export interface AuthedRequest extends Request {
userId?: string;
userEmail?: string;
}
/** Attach session user (if any) to the request. Never throws. */
export async function withSession(req: AuthedRequest, _res: Response, next: NextFunction) {
try {
const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
if (session?.user) {
req.userId = session.user.id;
req.userEmail = session.user.email;
}
} catch {
/* ignore */
}
next();
}
/** Require an authenticated user. */
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
if (!req.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
next();
}
/** Require an admin user. */
export async function requireAdmin(req: AuthedRequest, res: Response, next: NextFunction) {
if (!req.userId) return res.status(401).json({ error: 'Not authenticated' });
const ok = await hasRole(req.userId, 'admin');
if (!ok) return res.status(403).json({ error: 'Forbidden: admin only' });
next();
}
+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;
+103
View File
@@ -0,0 +1,103 @@
import { Router } from 'express';
import { query, one } from '../db.js';
import { insertRow } from '../lib/sql.js';
import { addCredits } from '../lib/credits.js';
import { h } from '../lib/respond.js';
import { requireAuth, requireAdmin, type AuthedRequest } from '../middleware/auth.js';
const r = Router();
const uid = (req: AuthedRequest) => req.userId as string;
// ---- user-facing ----
r.post('/', requireAuth, h(async (req) => {
const { amount, currency, plan_id, credit_pack_id, proof_of_payment_url } = req.body || {};
const row = await insertRow<any>('bank_transfer_requests', {
user_id: uid(req as any), amount, currency, plan_id, credit_pack_id, proof_of_payment_url,
});
return { id: row.id };
}));
r.get('/:id/status', requireAuth, h(async (req) =>
one(`SELECT status, admin_notes FROM public.bank_transfer_requests WHERE id = $1 AND user_id = $2`,
[req.params.id, uid(req as any)])
));
// ---- admin ----
r.get('/', requireAdmin, h(async () => {
const transfers = await query<any>(`SELECT * FROM public.bank_transfer_requests ORDER BY created_at DESC`);
if (!transfers.length) return [];
const userIds = [...new Set(transfers.map((t) => t.user_id))];
const profiles = await query<any>(
`SELECT id, email, display_name FROM public.profiles WHERE id = ANY($1::text[])`, [userIds]
);
const planIds = [...new Set(transfers.map((t) => t.plan_id).filter(Boolean))];
const packIds = [...new Set(transfers.map((t) => t.credit_pack_id).filter(Boolean))];
const plans = planIds.length ? await query<any>(`SELECT id, name, monthly_credits FROM public.subscription_plans WHERE id = ANY($1::uuid[])`, [planIds]) : [];
const packs = packIds.length ? await query<any>(`SELECT id, name, credits FROM public.credit_packs WHERE id = ANY($1::uuid[])`, [packIds]) : [];
const pMap = new Map(profiles.map((p) => [p.id, p]));
const planMap = new Map(plans.map((p) => [p.id, p]));
const packMap = new Map(packs.map((p) => [p.id, p]));
return transfers.map((t) => ({
...t,
profiles: pMap.get(t.user_id) || null,
subscription_plans: t.plan_id ? planMap.get(t.plan_id) || null : null,
credit_packs: t.credit_pack_id ? packMap.get(t.credit_pack_id) || null : null,
}));
}));
r.post('/:id/approve', requireAdmin, h(async (req) => {
const id = req.params.id;
const adminNotes = req.body?.adminNotes;
const transfer = await one<any>(`SELECT * FROM public.bank_transfer_requests WHERE id = $1`, [id]);
if (!transfer) throw new Error('Transfer not found');
await query(`UPDATE public.bank_transfer_requests SET status = 'approved', admin_notes = $2 WHERE id = $1`,
[id, adminNotes || null]);
if (transfer.credit_pack_id) {
const pack = await one<any>(`SELECT credits FROM public.credit_packs WHERE id = $1`, [transfer.credit_pack_id]);
if (pack?.credits) {
await addCredits(transfer.user_id, pack.credits, 'bonus', 'bank_transfer_purchase',
`Bank transfer approved - ${pack.credits} credits`);
}
}
if (transfer.plan_id) {
const updated = await query(
`UPDATE public.user_subscriptions
SET plan_id = $2, status = 'active', payment_method = 'bank_transfer',
current_period_start = now(), current_period_end = now() + interval '30 days'
WHERE user_id = $1`,
[transfer.user_id, transfer.plan_id]
);
// pg doesn't return rowCount via our helper; check existence
const exists = await one(`SELECT id FROM public.user_subscriptions WHERE user_id = $1`, [transfer.user_id]);
if (!exists) {
await insertRow('user_subscriptions', {
user_id: transfer.user_id, plan_id: transfer.plan_id, status: 'active',
payment_method: 'bank_transfer', current_period_end: new Date(Date.now() + 30 * 864e5).toISOString(),
});
}
const plan = await one<any>(`SELECT monthly_credits FROM public.subscription_plans WHERE id = $1`, [transfer.plan_id]);
if (plan?.monthly_credits > 0) {
await addCredits(transfer.user_id, plan.monthly_credits, 'monthly', 'subscription_activation',
`Subscription activated via bank transfer - ${plan.monthly_credits} monthly credits`);
}
}
await insertRow('payment_transactions', {
user_id: transfer.user_id, amount: transfer.amount, currency: transfer.currency,
payment_method: 'bank_transfer', transaction_type: transfer.credit_pack_id ? 'credit_purchase' : 'subscription',
status: 'completed', reference_id: id,
});
return { success: true };
}));
r.post('/:id/reject', requireAdmin, h(async (req) => {
await query(`UPDATE public.bank_transfer_requests SET status = 'rejected', admin_notes = $2 WHERE id = $1`,
[req.params.id, req.body?.adminNotes]);
return { success: true };
}));
export default r;
+74
View File
@@ -0,0 +1,74 @@
import { Router } from 'express';
import { query, one } from '../db.js';
import { insertRow } from '../lib/sql.js';
import { h } from '../lib/respond.js';
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
import { triggerCloudBuild, getCloudBuildStatus } from '../functions/cloud-build.js';
const r = Router();
r.use(requireAuth);
const uid = (req: AuthedRequest) => req.userId as string;
function sanitizeSegment(segment: string, fallback: string) {
const cleaned = segment.toLowerCase().replace(/[^a-z0-9_]/g, '');
if (!cleaned) return fallback;
return /^[a-z]/.test(cleaned) ? cleaned : `app${cleaned}`;
}
function sanitizePackageName(raw: string | undefined, appName: string) {
const fallback = ['com', 'app', sanitizeSegment(appName, 'mobile')];
const src = (raw || '').split('.').filter(Boolean);
const segs = src.length ? src.map((s, i) => sanitizeSegment(s, fallback[i] || 'app')) : fallback;
while (segs.length < 3) segs.push(fallback[segs.length] || 'app');
return segs.join('.');
}
// POST /builds -> start a build
r.post('/', h(async (req) => {
const config = req.body || {};
const platform = config.platform || 'android';
const appName = config.appName || 'My App';
const packageName = sanitizePackageName(config.packageName, appName);
const normalizedConfig = { ...config, packageName };
const build = await insertRow<any>('app_builds', {
user_id: uid(req as any),
app_name: appName,
package_name: packageName,
website_url: config.websiteUrl,
config: normalizedConfig,
status: 'pending',
progress: 0,
});
try {
const result = await triggerCloudBuild({
buildId: build.id,
websiteUrl: config.websiteUrl,
appName,
platform,
packageName,
config: normalizedConfig,
});
return { buildId: build.id, cloudBuildId: result.cloudBuildId, message: result.message };
} catch (e: any) {
return { buildId: build.id, message: 'Build created but cloud trigger failed: ' + e.message };
}
}));
// GET /builds?limit=
r.get('/', h(async (req) => {
const limit = Math.min(parseInt(String(req.query.limit ?? '20'), 10) || 20, 200);
return query(
`SELECT * FROM public.app_builds WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`,
[uid(req as any), limit]
);
}));
// GET /builds/:id/status
r.get('/:id/status', h(async (req) => {
const status = await getCloudBuildStatus(req.params.id);
if (status) return status;
return one(`SELECT * FROM public.app_builds WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
}));
export default r;
+166
View File
@@ -0,0 +1,166 @@
import { Router } from 'express';
import { query, one } from '../db.js';
import { hasRole, noAdminExists } from '../lib/roles.js';
import type { AuthedRequest } from '../middleware/auth.js';
/**
* Generic, guarded query endpoint backing the frontend backend-client.
* Supports the bounded subset of the query-builder the app actually uses.
*
* Access rules (RLS replacement):
* - PUBLIC_READ tables: readable without auth (were public via RLS).
* - ADMIN tables: read + write require admin.
* - other tables: read requires auth; write requires admin.
* - user_roles: write allowed during the first-admin "setup window"
* (no admin exists yet) — mirrors the original first-admin RLS policy.
*/
const r = Router();
const ALL_TABLES = new Set([
'profiles', 'user_roles', 'user_credits', 'user_subscriptions', 'subscription_plans',
'app_projects', 'app_builds', 'app_templates', 'automation_configs', 'automation_logs',
'credit_packs', 'credit_usage_history', 'payment_transactions', 'bank_transfer_requests',
'chat_messages', 'consent_records', 'email_templates', 'invoices', 'api_configurations',
'plugins', 'system_settings', 'settings_audit_log', 'payment_gateway_configs', 'webhook_event_logs',
]);
const PUBLIC_READ = new Set(['subscription_plans', 'credit_packs', 'system_settings']);
const ADMIN_TABLES = new Set([
'api_configurations', 'payment_gateway_configs', 'webhook_event_logs',
'settings_audit_log', 'plugins', 'email_templates',
]);
const JSON_COLUMNS = new Set([
'config', 'features', 'variables', 'value', 'metadata', 'items',
'sandbox_config', 'live_config', 'payload', 'old_value', 'new_value',
]);
const OPS: Record<string, string> = { eq: '=', neq: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' };
function placeholder(col: string, idx: number) {
return JSON_COLUMNS.has(col) ? `$${idx}::jsonb` : `$${idx}`;
}
function enc(col: string, val: any) {
if (JSON_COLUMNS.has(col) && val !== null && typeof val !== 'string') return JSON.stringify(val);
return val;
}
function buildWhere(filters: any[], startIdx: number): { clause: string; params: any[] } {
if (!filters?.length) return { clause: '', params: [] };
const parts: string[] = [];
const params: any[] = [];
let i = startIdx;
for (const f of filters) {
if (f.op === 'in') {
params.push(f.val);
parts.push(`"${f.col}" = ANY($${i}::text[])`);
i++;
} else if (OPS[f.op]) {
params.push(f.val);
parts.push(`"${f.col}" ${OPS[f.op]} $${i}`);
i++;
}
}
return { clause: parts.length ? 'WHERE ' + parts.join(' AND ') : '', params };
}
async function attachProfiles(rows: any[]) {
const ids = [...new Set(rows.map((x) => x.user_id).filter(Boolean))];
if (!ids.length) return rows;
const profs = await query<any>(
`SELECT id, email, display_name FROM public.profiles WHERE id = ANY($1::text[])`, [ids]
);
const map = new Map(profs.map((p) => [p.id, p]));
return rows.map((x) => ({ ...x, profiles: map.get(x.user_id) || null }));
}
r.post('/query', async (req: AuthedRequest, res) => {
try {
const { table, action = 'select', columns, filters = [], order, limit, single, values, onConflict, head, count } = req.body || {};
if (!ALL_TABLES.has(table)) return res.status(400).json({ error: `Table not allowed: ${table}` });
const isAdmin = req.userId ? await hasRole(req.userId, 'admin') : false;
const isRead = action === 'select';
// ---- authorization ----
if (isRead) {
if (!PUBLIC_READ.has(table)) {
if (!req.userId) return res.status(401).json({ error: 'Not authenticated' });
if (ADMIN_TABLES.has(table) && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
}
} else {
// writes
let allowed = isAdmin;
if (!allowed && table === 'user_roles' && req.userId) {
// first-admin setup window
allowed = await noAdminExists();
}
if (!allowed && table === 'profiles' && req.userId) {
// allow self profile insert/update
const selfFilter = (filters || []).find((f: any) => (f.col === 'id') && f.val === req.userId);
const selfValue = values && (Array.isArray(values) ? values : [values]).every((v: any) => !v.id || v.id === req.userId);
allowed = !!selfFilter || !!selfValue;
}
if (!allowed) return res.status(403).json({ error: 'Forbidden' });
}
// ---- execute ----
if (action === 'select') {
if (head && count === 'exact') {
const { clause, params } = buildWhere(filters, 1);
const row = await one<{ c: string }>(`SELECT count(*)::text c FROM public.${table} ${clause}`, params);
return res.json({ data: null, count: parseInt(row?.c || '0', 10) });
}
const { clause, params } = buildWhere(filters, 1);
let sql = `SELECT * FROM public.${table} ${clause}`;
if (order?.col) sql += ` ORDER BY "${order.col}" ${order.ascending === false ? 'DESC' : 'ASC'}`;
if (limit) sql += ` LIMIT ${parseInt(String(limit), 10)}`;
let rows = await query<any>(sql, params);
if (typeof columns === 'string' && columns.includes('profiles(')) rows = await attachProfiles(rows);
if (single) return res.json({ data: rows[0] ?? null, count: rows.length });
return res.json({ data: rows, count: rows.length });
}
if (action === 'insert' || action === 'upsert') {
const list = Array.isArray(values) ? values : [values];
const out: any[] = [];
for (const obj of list) {
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
const cols = keys.map((k) => `"${k}"`).join(', ');
const ph = keys.map((k, idx) => placeholder(k, idx + 1)).join(', ');
const params = keys.map((k) => enc(k, obj[k]));
let sql = `INSERT INTO public.${table} (${cols}) VALUES (${ph})`;
if (action === 'upsert' && onConflict) {
const updates = keys.filter((k) => k !== onConflict).map((k) => `"${k}" = EXCLUDED."${k}"`).join(', ');
sql += ` ON CONFLICT ("${onConflict}") DO UPDATE SET ${updates || `"${onConflict}" = EXCLUDED."${onConflict}"`}`;
}
sql += ' RETURNING *';
const row = await one(sql, params);
out.push(row);
}
return res.json({ data: single ? out[0] ?? null : out });
}
if (action === 'update') {
const obj = values || {};
const keys = Object.keys(obj).filter((k) => obj[k] !== undefined);
const sets = keys.map((k, idx) => `"${k}" = ${placeholder(k, idx + 1)}`).join(', ');
const setParams = keys.map((k) => enc(k, obj[k]));
const { clause, params } = buildWhere(filters, keys.length + 1);
const sql = `UPDATE public.${table} SET ${sets} ${clause} RETURNING *`;
const rows = await query<any>(sql, [...setParams, ...params]);
return res.json({ data: single ? rows[0] ?? null : rows });
}
if (action === 'delete') {
const { clause, params } = buildWhere(filters, 1);
if (!clause) return res.status(400).json({ error: 'Refusing unfiltered delete' });
await query(`DELETE FROM public.${table} ${clause}`, params);
return res.json({ data: null });
}
return res.status(400).json({ error: `Unknown action: ${action}` });
} catch (err: any) {
return res.status(400).json({ error: err?.message || 'Query failed' });
}
});
export default r;
+97
View File
@@ -0,0 +1,97 @@
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, type AuthedRequest } from '../middleware/auth.js';
const uid = (req: AuthedRequest) => req.userId as string;
// ---------- Automation ----------
export const automationRouter = Router();
automationRouter.use(requireAuth);
automationRouter.get('/', h(async (req) => {
const projectId = req.query.projectId as string | undefined;
const rows = projectId
? await query(`SELECT * FROM public.automation_configs WHERE user_id = $1 AND project_id = $2 ORDER BY created_at DESC`, [uid(req as any), projectId])
: await query(`SELECT * FROM public.automation_configs WHERE user_id = $1 ORDER BY created_at DESC`, [uid(req as any)]);
return { automations: rows };
}));
automationRouter.post('/', h(async (req) => {
const { projectId, workflowType, config } = req.body || {};
return insertRow('automation_configs', { user_id: uid(req as any), project_id: projectId, workflow_type: workflowType, config });
}));
automationRouter.patch('/:id/toggle', h(async (req) => {
return updateById('automation_configs', req.params.id, { is_enabled: req.body?.enabled });
}));
automationRouter.patch('/:id/config', h(async (req) => {
return updateById('automation_configs', req.params.id, { config: req.body?.config });
}));
automationRouter.get('/:id/logs', h(async (req) => {
const logs = await query(`SELECT * FROM public.automation_logs WHERE automation_id = $1 ORDER BY created_at DESC`, [req.params.id]);
return { logs };
}));
automationRouter.post('/:id/execute', h(async () => ({ message: 'Automation execution triggered' })));
automationRouter.delete('/:id', h(async (req) => {
await query(`DELETE FROM public.automation_configs WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
return { message: 'Automation deleted' };
}));
// ---------- Templates ----------
export const templatesRouter = Router();
templatesRouter.use(requireAuth);
templatesRouter.get('/', h(async (req) =>
query(`SELECT * FROM public.app_templates WHERE user_id = $1 ORDER BY created_at DESC`, [uid(req as any)])
));
templatesRouter.post('/', h(async (req) => {
const { name, description, config } = req.body || {};
return insertRow('app_templates', { name, description, config, user_id: uid(req as any) });
}));
templatesRouter.delete('/:id', h(async (req) => {
await query(`DELETE FROM public.app_templates WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
return { message: 'Template deleted' };
}));
// ---------- Plans (public) ----------
export const plansRouter = Router();
plansRouter.get('/', h(async () =>
query(`SELECT * FROM public.subscription_plans WHERE is_active = true ORDER BY price_monthly ASC`)
));
plansRouter.get('/:id', h(async (req) =>
one(`SELECT * FROM public.subscription_plans WHERE id = $1`, [req.params.id])
));
// ---------- Credit packs (public) ----------
export const creditPacksRouter = Router();
creditPacksRouter.get('/', h(async () =>
query(`SELECT * FROM public.credit_packs WHERE is_active = true ORDER BY price ASC`)
));
creditPacksRouter.get('/:id', h(async (req) =>
one(`SELECT * FROM public.credit_packs WHERE id = $1`, [req.params.id])
));
// ---------- Chat ----------
export const chatRouter = Router();
chatRouter.use(requireAuth);
chatRouter.get('/', h(async (req) => {
const projectId = req.query.projectId as string;
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 500);
return query(
`SELECT * FROM public.chat_messages WHERE user_id = $1 AND project_id = $2 ORDER BY created_at ASC LIMIT $3`,
[uid(req as any), projectId, limit]
);
}));
chatRouter.post('/', h(async (req) => {
const { projectId, role, content } = req.body || {};
const row = await insertRow<any>('chat_messages', { user_id: uid(req as any), project_id: projectId, role, content });
return { id: row.id };
}));
chatRouter.delete('/', h(async (req) => {
await query(`DELETE FROM public.chat_messages WHERE user_id = $1 AND project_id = $2`, [uid(req as any), req.query.projectId]);
return { message: 'Chat history cleared' };
}));
// ---------- Setup / public role checks ----------
export const setupRouter = Router();
import { noAdminExists } from '../lib/roles.js';
setupRouter.get('/no-admin-exists', h(async () => ({ result: await noAdminExists() })));
+53
View File
@@ -0,0 +1,53 @@
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, type AuthedRequest } from '../middleware/auth.js';
const r = Router();
r.use(requireAuth);
const uid = (req: AuthedRequest) => req.userId as string;
r.get('/', h(async (req) => {
return query(
`SELECT * FROM public.app_projects WHERE user_id = $1 ORDER BY updated_at DESC`,
[uid(req as any)]
);
}));
// builds for current user (optionally filtered by project) — must be before /:id
r.get('/builds', h(async (req) => {
const projectId = req.query.projectId as string | undefined;
if (projectId) {
return query(
`SELECT * FROM public.app_builds WHERE user_id = $1 AND project_id = $2 ORDER BY created_at DESC`,
[uid(req as any), projectId]
);
}
return query(
`SELECT * FROM public.app_builds WHERE user_id = $1 ORDER BY created_at DESC`,
[uid(req as any)]
);
}));
r.get('/:id', h(async (req) => {
return one(`SELECT * FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
}));
r.post('/', h(async (req) => {
return insertRow('app_projects', { ...req.body, user_id: uid(req as any) });
}));
r.patch('/:id', h(async (req) => {
// ownership check
const owned = await one(`SELECT id FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
if (!owned) return null;
return updateById('app_projects', req.params.id, req.body || {});
}));
r.delete('/:id', h(async (req) => {
await query(`DELETE FROM public.app_projects WHERE id = $1 AND user_id = $2`, [req.params.id, uid(req as any)]);
return { message: 'Project deleted' };
}));
export default r;
+38
View File
@@ -0,0 +1,38 @@
import { Router } from 'express';
import multer from 'multer';
import { h } from '../lib/respond.js';
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
import { writeFile, deleteFile, listFiles, publicUrl } from '../lib/storage.js';
const r = Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 600 * 1024 * 1024 } });
// POST /storage/upload (multipart: bucket, path, file)
r.post('/upload', requireAuth, upload.single('file'), h(async (req: AuthedRequest) => {
const bucket = req.body.bucket as string;
const relPath = (req.body.path as string) || (req as any).file?.originalname;
const file = (req as any).file;
if (!file) throw new Error('No file provided');
if (!bucket) throw new Error('No bucket provided');
const result = writeFile(bucket, relPath, file.buffer);
return result;
}));
// DELETE /storage (body: bucket, path)
r.delete('/', requireAuth, h(async (req) => {
const { bucket, path: relPath } = req.body || {};
deleteFile(bucket, relPath);
return { message: 'File deleted successfully' };
}));
// GET /storage/list?bucket=&folder=
r.get('/list', requireAuth, h(async (req) => {
return listFiles(req.query.bucket as string, (req.query.folder as string) || '');
}));
// GET /storage/public-url?bucket=&path=
r.get('/public-url', h(async (req) => {
return { url: publicUrl(req.query.bucket as string, req.query.path as string) };
}));
export default r;
+96
View File
@@ -0,0 +1,96 @@
import { Router } from 'express';
import { query, one } from '../db.js';
import { updateById } from '../lib/sql.js';
import { useCredits } from '../lib/credits.js';
import { getUserRole } from '../lib/roles.js';
import { h } from '../lib/respond.js';
import { requireAuth, type AuthedRequest } from '../middleware/auth.js';
const r = Router();
r.use(requireAuth);
const uid = (req: AuthedRequest) => req.userId as string;
r.get('/profile', h(async (req) => {
return one(`SELECT * FROM public.profiles WHERE id = $1`, [uid(req as any)]);
}));
r.patch('/profile', h(async (req) => {
return updateById('profiles', uid(req as any), req.body || {});
}));
r.get('/credits', h(async (req) => {
return one(`SELECT * FROM public.user_credits WHERE user_id = $1`, [uid(req as any)]);
}));
r.post('/credits/use', h(async (req) => {
const { amount, actionType, description } = req.body || {};
const success = await useCredits(uid(req as any), Number(amount), actionType, description ?? null);
return { success };
}));
r.get('/subscription', h(async (req) => {
const sub = await one<any>(
`SELECT * FROM public.user_subscriptions WHERE user_id = $1`,
[uid(req as any)]
);
if (!sub) return null;
const plan = await one(`SELECT * FROM public.subscription_plans WHERE id = $1`, [sub.plan_id]);
return { ...sub, plan };
}));
r.get('/credit-history', h(async (req) => {
const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 500);
return query(
`SELECT * FROM public.credit_usage_history WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`,
[uid(req as any), limit]
);
}));
r.get('/transactions', h(async (req) => {
return query(
`SELECT * FROM public.payment_transactions WHERE user_id = $1 ORDER BY created_at DESC`,
[uid(req as any)]
);
}));
r.get('/invoices', h(async (req) => {
return query(
`SELECT * FROM public.invoices WHERE user_id = $1 ORDER BY created_at DESC`,
[uid(req as any)]
);
}));
r.get('/role', h(async (req) => {
return { role: await getUserRole(uid(req as any)) };
}));
r.get('/export', h(async (req) => {
const id = uid(req as any);
const [profile, credits, projects, builds, creditHistory] = await Promise.all([
one(`SELECT * FROM public.profiles WHERE id = $1`, [id]),
one(`SELECT * FROM public.user_credits WHERE user_id = $1`, [id]),
query(`SELECT * FROM public.app_projects WHERE user_id = $1`, [id]),
query(`SELECT * FROM public.app_builds WHERE user_id = $1`, [id]),
query(`SELECT * FROM public.credit_usage_history WHERE user_id = $1`, [id]),
]);
return { profile, credits, projects, builds, creditHistory, exportedAt: new Date().toISOString() };
}));
r.delete('/account', h(async (req) => {
const id = uid(req as any);
await query(`DELETE FROM public.credit_usage_history WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.user_credits WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.user_subscriptions WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.app_builds WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.app_projects WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.user_roles WHERE user_id = $1`, [id]);
await query(`DELETE FROM public.profiles WHERE id = $1`, [id]);
// Better Auth user + sessions
await query(`DELETE FROM "session" WHERE "userId" = $1`, [id]).catch(() => {});
await query(`DELETE FROM "account" WHERE "userId" = $1`, [id]).catch(() => {});
await query(`DELETE FROM "user" WHERE id = $1`, [id]).catch(() => {});
return { message: 'Account deleted' };
}));
export default r;
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}