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,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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { env } from '../env.js';
|
||||
import { one } from '../db.js';
|
||||
|
||||
const systemPrompt = `You are an expert mobile app designer. Analyze the given website URL and suggest optimal mobile app configuration settings. Respond ONLY with valid JSON (no markdown) with fields: app_name (max 20 chars), primary_color (hex), accent_color (hex), navigation_style (one of bottom-nav, drawer, tabs), features (array of 3-6 of offline_mode, push_notifications, dark_mode, share, favorites, search), app_category (news, shopping, social, business, education, entertainment, lifestyle, utility), description (max 100 chars), icon_style (modern, classic, minimal, rounded, gradient), splash_screen_style (centered-logo, full-bleed, minimal, animated).`;
|
||||
|
||||
function domainOf(websiteUrl: string): string {
|
||||
try {
|
||||
return new URL(websiteUrl).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return websiteUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function heuristicConfig(websiteUrl: string) {
|
||||
const domain = domainOf(websiteUrl);
|
||||
const base = domain.split('.')[0] || 'app';
|
||||
return {
|
||||
app_name: base.charAt(0).toUpperCase() + base.slice(1),
|
||||
primary_color: '#3B82F6',
|
||||
accent_color: '#8B5CF6',
|
||||
navigation_style: 'bottom-nav',
|
||||
features: ['offline_mode', 'push_notifications', 'dark_mode'],
|
||||
app_category: 'utility',
|
||||
description: `Mobile app for ${domain}`,
|
||||
icon_style: 'modern',
|
||||
splash_screen_style: 'centered-logo',
|
||||
};
|
||||
}
|
||||
|
||||
async function callOpenAI(apiKey: string, websiteUrl: string): Promise<any | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: `Analyze this website: ${websiteUrl} (domain: ${domainOf(websiteUrl)}). Return JSON only.` },
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 600,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
let content = (data.choices?.[0]?.message?.content || '').trim();
|
||||
if (content.startsWith('```json')) content = content.slice(7);
|
||||
else if (content.startsWith('```')) content = content.slice(3);
|
||||
if (content.endsWith('```')) content = content.slice(0, -3);
|
||||
return JSON.parse(content.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns { config } — AI-powered when OPENAI_API_KEY (or an admin-configured
|
||||
* AI provider) is available, otherwise a sensible heuristic so the builder
|
||||
* flow always works.
|
||||
*/
|
||||
export async function analyzeWebsite(websiteUrl: string): Promise<{ config: any }> {
|
||||
// env key first
|
||||
let apiKey = env.OPENAI_API_KEY;
|
||||
|
||||
// admin-configured AI provider (api_configurations) as fallback
|
||||
if (!apiKey) {
|
||||
const cfg = await one<{ config: any }>(
|
||||
`SELECT config FROM public.api_configurations WHERE provider = 'ai' AND is_active = true LIMIT 1`
|
||||
).catch(() => null);
|
||||
apiKey = cfg?.config?.openai_api_key || '';
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
const aiConfig = await callOpenAI(apiKey, websiteUrl);
|
||||
if (aiConfig) return { config: aiConfig };
|
||||
}
|
||||
|
||||
return { config: heuristicConfig(websiteUrl) };
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { query, one } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
interface TriggerArgs {
|
||||
buildId: string;
|
||||
websiteUrl: string;
|
||||
appName: string;
|
||||
platform: string;
|
||||
packageName: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a cloud build.
|
||||
*
|
||||
* If CODEMAGIC_API_TOKEN + CODEMAGIC_APP_ID are configured, this calls the
|
||||
* Codemagic API for real. Otherwise it runs a local SIMULATION so the build
|
||||
* flow is demonstrable in development (progress + placeholder artifact).
|
||||
*/
|
||||
export async function triggerCloudBuild(args: TriggerArgs): Promise<{ cloudBuildId?: string; message: string }> {
|
||||
if (env.CODEMAGIC_API_TOKEN && env.CODEMAGIC_APP_ID) {
|
||||
const res = await fetch('https://api.codemagic.io/builds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-auth-token': env.CODEMAGIC_API_TOKEN },
|
||||
body: JSON.stringify({
|
||||
appId: env.CODEMAGIC_APP_ID,
|
||||
workflowId: args.platform === 'ios' ? 'ios-workflow' : 'android-workflow',
|
||||
branch: 'main',
|
||||
environment: {
|
||||
variables: {
|
||||
APP_NAME: args.appName,
|
||||
WEBSITE_URL: args.websiteUrl,
|
||||
PACKAGE_NAME: args.packageName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json?.error || json?.message || 'Codemagic build trigger failed');
|
||||
const cloudBuildId = json?.buildId || json?._id;
|
||||
await query(`UPDATE public.app_builds SET status = 'building', progress = 5 WHERE id = $1`, [args.buildId]);
|
||||
return { cloudBuildId, message: 'Cloud build started (Codemagic)' };
|
||||
}
|
||||
|
||||
// ---- Simulation (no Codemagic configured) ----
|
||||
await query(`UPDATE public.app_builds SET status = 'building', progress = 10 WHERE id = $1`, [args.buildId]);
|
||||
simulateBuild(args.buildId).catch((e) => console.error('[cloud-build sim]', e.message));
|
||||
return { message: 'Cloud build simulated (Codemagic not configured)' };
|
||||
}
|
||||
|
||||
async function simulateBuild(buildId: string) {
|
||||
const steps = [25, 45, 65, 85];
|
||||
for (const p of steps) {
|
||||
await delay(2000);
|
||||
await query(`UPDATE public.app_builds SET progress = $2 WHERE id = $1 AND status = 'building'`, [buildId, p]);
|
||||
}
|
||||
await delay(2000);
|
||||
const build = await one<any>(`SELECT package_name FROM public.app_builds WHERE id = $1`, [buildId]);
|
||||
const fileName = `${(build?.package_name || 'app').replace(/\./g, '-')}.apk`;
|
||||
await query(
|
||||
`UPDATE public.app_builds
|
||||
SET status = 'complete', progress = 100,
|
||||
download_url = $2, file_size_bytes = $3
|
||||
WHERE id = $1 AND status = 'building'`,
|
||||
[buildId, `/storage/apk-builds/simulated/${fileName}`, 12 * 1024 * 1024]
|
||||
);
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
/** Returns the current build row (used by the status endpoint). */
|
||||
export async function getCloudBuildStatus(buildId: string) {
|
||||
return one(`SELECT * FROM public.app_builds WHERE id = $1`, [buildId]);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Router } from 'express';
|
||||
import { env } from '../env.js';
|
||||
import { query, one } from '../db.js';
|
||||
import { ensureBuckets, BUCKETS } from '../lib/storage.js';
|
||||
import { analyzeWebsite } from './analyze-website.js';
|
||||
import { triggerCloudBuild, getCloudBuildStatus } from './cloud-build.js';
|
||||
import type { AuthedRequest } from '../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Server-side functions (payments, build, email, AI, storage helpers).
|
||||
* Reachable at both POST /api/functions/:name (primary) and
|
||||
* GET|POST /api/functions/v1/:name (alias used by some admin screens).
|
||||
*/
|
||||
const r = Router();
|
||||
|
||||
type Ctx = { body: any; query: any; req: AuthedRequest };
|
||||
type Handler = (ctx: Ctx) => Promise<any>;
|
||||
|
||||
const NOT_CONFIGURED = (gateway: string) =>
|
||||
new Error(`${gateway} is not configured. Add the API keys to your server .env to enable it.`);
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
'analyze-website': async ({ body }) => {
|
||||
const url = body?.websiteUrl || body?.url;
|
||||
if (!url) throw new Error('websiteUrl is required');
|
||||
return analyzeWebsite(url);
|
||||
},
|
||||
|
||||
'cloud-build': async ({ body }) => triggerCloudBuild(body),
|
||||
|
||||
'cloud-build-status': async ({ body }) => {
|
||||
const status = await getCloudBuildStatus(body?.buildId);
|
||||
if (!status) throw new Error('Build not found');
|
||||
return status;
|
||||
},
|
||||
|
||||
'send-email': async ({ body }) => {
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.log('[send-email] (stub) would send:', body?.to, body?.subject);
|
||||
return { success: true, stubbed: true, message: 'Email logged (Resend not configured)' };
|
||||
}
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
from: body.from || 'AppForge <onboarding@resend.dev>',
|
||||
to: body.to, subject: body.subject, html: body.html || body.htmlContent,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.message || 'Email send failed');
|
||||
return { success: true, id: json.id };
|
||||
},
|
||||
|
||||
'test-storage-connection': async () => {
|
||||
ensureBuckets();
|
||||
return { success: true, message: 'Local storage is available', provider: 'local-filesystem' };
|
||||
},
|
||||
|
||||
'storage-admin': async ({ query: q }) => {
|
||||
ensureBuckets();
|
||||
const action = q?.action || 'list';
|
||||
if (action === 'list') {
|
||||
return BUCKETS.map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
public: name !== 'project-assets',
|
||||
file_size_limit: null,
|
||||
created_at: new Date(0).toISOString(),
|
||||
}));
|
||||
}
|
||||
// create/delete are no-ops for fixed local buckets
|
||||
return { success: true, message: `Local storage uses fixed buckets; '${action}' is a no-op.` };
|
||||
},
|
||||
|
||||
'ai-assistant': async ({ body }) => {
|
||||
const messages = body?.messages || (body?.message ? [{ role: 'user', content: body.message }] : []);
|
||||
const apiKey = env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return { reply: 'The AI assistant is not configured. Add OPENAI_API_KEY to the server .env to enable it.', stubbed: true };
|
||||
}
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'gpt-4o-mini', messages, temperature: 0.7 }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json?.error?.message || 'AI request failed');
|
||||
return { reply: json.choices?.[0]?.message?.content || '' };
|
||||
},
|
||||
|
||||
'reset-demo-data': async () => ({ success: true, message: 'Demo data reset not applicable in self-hosted mode' }),
|
||||
|
||||
'retry-webhook': async ({ body }) => {
|
||||
if (!body?.id) throw new Error('Webhook log id required');
|
||||
await query(`UPDATE public.webhook_event_logs SET status = 'retried' WHERE id = $1`, [body.id]);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
'stripe-checkout': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||||
'stripe-portal': async () => { throw NOT_CONFIGURED('Stripe'); },
|
||||
'stripe-webhook': async () => ({ received: true }),
|
||||
'paypal-checkout': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||||
'paypal-billing': async () => { throw NOT_CONFIGURED('PayPal'); },
|
||||
'paypal-webhook': async () => ({ received: true }),
|
||||
'coinbase-checkout': async () => { throw NOT_CONFIGURED('Coinbase'); },
|
||||
'coinbase-webhook': async () => ({ received: true }),
|
||||
};
|
||||
|
||||
const PUBLIC = new Set(['cloud-build-status', 'test-storage-connection', 'storage-admin',
|
||||
'stripe-webhook', 'paypal-webhook', 'coinbase-webhook']);
|
||||
|
||||
async function dispatch(req: AuthedRequest, res: any) {
|
||||
const name = req.params.name;
|
||||
|
||||
// health probe used by edge-function-health.ts
|
||||
if (req.query?.health) return res.json({ ok: true, function: name });
|
||||
|
||||
const handler = handlers[name];
|
||||
if (!handler) return res.status(404).json({ error: `Unknown function: ${name}` });
|
||||
if (!PUBLIC.has(name) && !req.userId) return res.status(401).json({ error: 'Not authenticated' });
|
||||
|
||||
try {
|
||||
const result = await handler({ body: req.body || {}, query: req.query || {}, req });
|
||||
res.json(result ?? null);
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: err?.message || 'Function failed' });
|
||||
}
|
||||
}
|
||||
|
||||
r.post('/:name', dispatch);
|
||||
r.get('/:name', dispatch);
|
||||
r.post('/v1/:name', dispatch);
|
||||
r.get('/v1/:name', dispatch);
|
||||
|
||||
export default r;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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() })));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user