Skip to content

Commit 7dd20c7

Browse files
createpjfclaude
andcommitted
feat: admin 4-feature upgrade + Aurora UI redesign
Cloud Gateway: - Model-level plan access control (allowed_plans per model in registry) - Credit packages moved from hardcode to DB with admin CRUD - User detail drill-down modal (transactions/requests/model breakdown) - Announcement system (DB-backed, served to desktop via /account/announcement) - Admin frontend full Aurora dark UI (glassmorphism, purple/cyan/green palette, animated gradient mesh background, Fira Sans/Code typography) Migrations: - 006: ALTER model_registry ADD allowed_plans text[] - 007: CREATE credit_packages table + seed - 008: CREATE announcements table Desktop: - api.ts: cloudGetAnnouncement() - App.tsx: fetch + display cloud announcements as AlertBanner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7ad8a8d commit 7dd20c7

File tree

19 files changed

+1483
-235
lines changed

19 files changed

+1483
-235
lines changed

apps/cloud-gateway/admin/index.html

Lines changed: 756 additions & 120 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- ---------------------------------------------------------------------------
2+
-- Migration 006: Add allowed_plans to model_registry
3+
-- Allows per-model plan access control (free / pro / all)
4+
-- ---------------------------------------------------------------------------
5+
6+
ALTER TABLE model_registry
7+
ADD COLUMN IF NOT EXISTS allowed_plans text[] NOT NULL DEFAULT ARRAY['all'];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
-- ---------------------------------------------------------------------------
2+
-- Migration 007: DB-backed credit packages
3+
-- Moves CREDIT_PACKAGES from hardcoded code to database
4+
-- ---------------------------------------------------------------------------
5+
6+
CREATE TABLE IF NOT EXISTS credit_packages (
7+
id text PRIMARY KEY,
8+
polar_product_id text NOT NULL DEFAULT '',
9+
amount_cents int NOT NULL,
10+
credits_cents int NOT NULL,
11+
label text NOT NULL,
12+
bonus text,
13+
is_active boolean NOT NULL DEFAULT true,
14+
sort_order int NOT NULL DEFAULT 0,
15+
created_at timestamptz DEFAULT now()
16+
);
17+
18+
-- Seed initial data (update polar_product_id via admin after migration)
19+
INSERT INTO credit_packages (id, polar_product_id, amount_cents, credits_cents, label, bonus, is_active, sort_order) VALUES
20+
('credits_5', '', 500, 500, '$5', NULL, true, 0),
21+
('credits_20', '', 2000, 2200, '$20', '+10%', true, 1)
22+
ON CONFLICT DO NOTHING;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- ---------------------------------------------------------------------------
2+
-- Migration 008: Announcements system
3+
-- Admin-managed banners displayed to desktop users
4+
-- ---------------------------------------------------------------------------
5+
6+
CREATE TABLE IF NOT EXISTS announcements (
7+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
8+
title text NOT NULL,
9+
message text NOT NULL,
10+
type text NOT NULL DEFAULT 'info', -- info | warning | error
11+
starts_at timestamptz DEFAULT now(),
12+
ends_at timestamptz,
13+
is_active boolean NOT NULL DEFAULT true,
14+
created_at timestamptz DEFAULT now()
15+
);
16+
17+
CREATE INDEX IF NOT EXISTS idx_announcements_active ON announcements (is_active, starts_at, ends_at);

apps/cloud-gateway/src/lib/admin-queries.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,111 @@ export async function adjustUserBalance(
372372
return result;
373373
}
374374

375+
// ── User detail (single user) ─────────────────────────────────────────────
376+
377+
export async function getAdminUser(userId: string) {
378+
const [row] = await sql`
379+
SELECT
380+
u.id, u.email, u.display_name, u.plan, u.created_at, u.updated_at,
381+
COALESCE(c.balance_cents, 0) AS balance_cents,
382+
COALESCE(c.total_deposited_cents, 0) AS total_deposited_cents,
383+
COALESCE(c.total_used_cents, 0) AS total_used_cents,
384+
(SELECT COUNT(*)::int FROM requests WHERE user_id = u.id) AS request_count,
385+
(SELECT COUNT(*)::int FROM transactions WHERE user_id = u.id) AS transaction_count
386+
FROM users u
387+
LEFT JOIN credits c ON c.user_id = u.id
388+
WHERE u.id = ${userId}
389+
`;
390+
if (!row) return null;
391+
return {
392+
id: row.id,
393+
email: row.email,
394+
displayName: row.display_name,
395+
plan: row.plan,
396+
createdAt: row.created_at,
397+
updatedAt: row.updated_at,
398+
balanceCents: row.balance_cents,
399+
totalDepositedCents: row.total_deposited_cents,
400+
totalUsedCents: row.total_used_cents,
401+
requestCount: row.request_count,
402+
transactionCount: row.transaction_count,
403+
};
404+
}
405+
406+
export async function getUserTransactions(userId: string, limit = 50, offset = 0) {
407+
const rows = await sql`
408+
SELECT
409+
id, type, amount_cents, balance_after_cents, description, payment_ref, model, created_at,
410+
COUNT(*) OVER() AS total_count
411+
FROM transactions
412+
WHERE user_id = ${userId}
413+
ORDER BY created_at DESC
414+
LIMIT ${limit} OFFSET ${offset}
415+
`;
416+
const total = rows.length > 0 ? Number(rows[0].total_count) : 0;
417+
return {
418+
transactions: rows.map((r) => ({
419+
id: r.id,
420+
type: r.type,
421+
amountCents: r.amount_cents,
422+
balanceAfterCents: r.balance_after_cents,
423+
description: r.description,
424+
paymentRef: r.payment_ref,
425+
model: r.model,
426+
createdAt: r.created_at,
427+
})),
428+
total,
429+
};
430+
}
431+
432+
export async function getUserRequests(userId: string, limit = 50, offset = 0) {
433+
const rows = await sql`
434+
SELECT
435+
id, model, provider, input_tokens, output_tokens, cost_cents, latency_ms, status, created_at,
436+
COUNT(*) OVER() AS total_count
437+
FROM requests
438+
WHERE user_id = ${userId}
439+
ORDER BY created_at DESC
440+
LIMIT ${limit} OFFSET ${offset}
441+
`;
442+
const total = rows.length > 0 ? Number(rows[0].total_count) : 0;
443+
return {
444+
requests: rows.map((r) => ({
445+
id: r.id,
446+
model: r.model,
447+
provider: r.provider,
448+
inputTokens: r.input_tokens,
449+
outputTokens: r.output_tokens,
450+
costCents: r.cost_cents,
451+
latencyMs: r.latency_ms,
452+
status: r.status,
453+
createdAt: r.created_at,
454+
})),
455+
total,
456+
};
457+
}
458+
459+
export async function getUserModelBreakdown(userId: string, days = 30) {
460+
const rows = await sql`
461+
SELECT
462+
model,
463+
COUNT(*)::int AS requests,
464+
COALESCE(SUM(cost_cents), 0)::int AS cost_cents,
465+
COALESCE(SUM(input_tokens + output_tokens), 0)::bigint AS total_tokens
466+
FROM requests
467+
WHERE user_id = ${userId}
468+
AND created_at >= CURRENT_DATE - make_interval(days => ${days})
469+
GROUP BY model
470+
ORDER BY requests DESC
471+
`;
472+
return rows.map((r) => ({
473+
model: r.model,
474+
requests: r.requests,
475+
costCents: r.cost_cents,
476+
totalTokens: Number(r.total_tokens),
477+
}));
478+
}
479+
375480
// ── Recent referral claims ───────────────────────────────────────────────
376481

377482
export async function getAdminRecentReferralClaims(limit = 50) {

apps/cloud-gateway/src/lib/model-registry.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export interface ModelRegistryEntry {
3535

3636
isFlockNode: boolean;
3737

38+
allowedPlans: string[];
39+
3840
createdAt: Date;
3941
updatedAt: Date;
4042
}
@@ -64,6 +66,7 @@ function rowToEntry(r: Record<string, unknown>): ModelRegistryEntry {
6466
priceInput: r.price_input as number,
6567
priceOutput: r.price_output as number,
6668
isFlockNode: r.is_flock_node as boolean,
69+
allowedPlans: (r.allowed_plans as string[]) ?? ["all"],
6770
createdAt: r.created_at as Date,
6871
updatedAt: r.updated_at as Date,
6972
};
@@ -104,13 +107,14 @@ export async function createModel(data: {
104107
priceInput?: number;
105108
priceOutput?: number;
106109
isFlockNode?: boolean;
110+
allowedPlans?: string[];
107111
}): Promise<ModelRegistryEntry> {
108112
const [row] = await sql`
109113
INSERT INTO model_registry (
110114
model_id, display_name, provider, status, tier,
111115
quality, speed, cost_efficiency, code_strength,
112116
supports_vision, supports_function_call, supports_long_context, chinese_optimized,
113-
max_context_tokens, avg_ttft_ms, price_input, price_output, is_flock_node
117+
max_context_tokens, avg_ttft_ms, price_input, price_output, is_flock_node, allowed_plans
114118
) VALUES (
115119
${data.modelId},
116120
${data.displayName},
@@ -129,7 +133,8 @@ export async function createModel(data: {
129133
${data.avgTtftMs ?? 500},
130134
${data.priceInput ?? 1.0},
131135
${data.priceOutput ?? 3.0},
132-
${data.isFlockNode ?? false}
136+
${data.isFlockNode ?? false},
137+
${data.allowedPlans ?? ["all"]}
133138
)
134139
RETURNING *
135140
`;
@@ -156,6 +161,7 @@ export async function updateModel(
156161
priceInput: number;
157162
priceOutput: number;
158163
isFlockNode: boolean;
164+
allowedPlans: string[];
159165
}>,
160166
): Promise<ModelRegistryEntry | null> {
161167
// Map camelCase keys to snake_case DB columns
@@ -177,6 +183,7 @@ export async function updateModel(
177183
priceInput: "price_input",
178184
priceOutput: "price_output",
179185
isFlockNode: "is_flock_node",
186+
allowedPlans: "allowed_plans",
180187
};
181188

182189
// Build a single atomic UPDATE with all changed fields

apps/cloud-gateway/src/lib/polar.ts

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { Polar } from "@polar-sh/sdk";
66
import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";
7+
import { sql } from "./db-cloud";
78

89
const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN;
910
const POLAR_WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;
@@ -33,25 +34,65 @@ export interface CreditPackage {
3334
credits: number; // credits added in cents
3435
label: string;
3536
bonus?: string;
37+
isActive?: boolean;
38+
sortOrder?: number;
3639
}
3740

38-
export const CREDIT_PACKAGES: CreditPackage[] = [
39-
{
40-
id: "credits_5",
41-
polarProductId: process.env.POLAR_PRODUCT_CREDIT_5 ?? "",
42-
amount: 500,
43-
credits: 500,
44-
label: "$5",
45-
},
46-
{
47-
id: "credits_20",
48-
polarProductId: process.env.POLAR_PRODUCT_CREDIT_20 ?? "",
49-
amount: 2000,
50-
credits: 2200,
51-
label: "$20",
52-
bonus: "+10%",
53-
},
54-
];
41+
// ---------------------------------------------------------------------------
42+
// DB-backed credit packages (replaces hardcoded CREDIT_PACKAGES)
43+
// ---------------------------------------------------------------------------
44+
45+
let _packagesCache: CreditPackage[] | null = null;
46+
47+
export async function loadCreditPackages(): Promise<CreditPackage[]> {
48+
if (_packagesCache) return _packagesCache;
49+
50+
try {
51+
const rows = await sql`
52+
SELECT id, polar_product_id, amount_cents, credits_cents, label, bonus, is_active, sort_order
53+
FROM credit_packages
54+
ORDER BY sort_order, id
55+
`;
56+
_packagesCache = rows.map((r) => ({
57+
id: r.id as string,
58+
polarProductId: r.polar_product_id as string,
59+
amount: r.amount_cents as number,
60+
credits: r.credits_cents as number,
61+
label: r.label as string,
62+
bonus: r.bonus as string | undefined,
63+
isActive: r.is_active as boolean,
64+
sortOrder: r.sort_order as number,
65+
}));
66+
} catch {
67+
// Fallback to env-var defaults if table not yet migrated
68+
_packagesCache = [
69+
{
70+
id: "credits_5",
71+
polarProductId: process.env.POLAR_PRODUCT_CREDIT_5 ?? "",
72+
amount: 500,
73+
credits: 500,
74+
label: "$5",
75+
isActive: true,
76+
sortOrder: 0,
77+
},
78+
{
79+
id: "credits_20",
80+
polarProductId: process.env.POLAR_PRODUCT_CREDIT_20 ?? "",
81+
amount: 2000,
82+
credits: 2200,
83+
label: "$20",
84+
bonus: "+10%",
85+
isActive: true,
86+
sortOrder: 1,
87+
},
88+
];
89+
}
90+
return _packagesCache;
91+
}
92+
93+
export function reloadCreditPackages(): void {
94+
_packagesCache = null;
95+
}
5596

5697
// ---------------------------------------------------------------------------
5798
// Subscription plans — monthly subscriptions with reduced markup
@@ -99,7 +140,8 @@ export async function createCheckoutSession(
99140
_email: string,
100141
packageId: string,
101142
) {
102-
const pkg = CREDIT_PACKAGES.find((p) => p.id === packageId);
143+
const packages = await loadCreditPackages();
144+
const pkg = packages.find((p) => p.id === packageId && p.isActive !== false);
103145
if (!pkg) throw new Error(`Invalid package: ${packageId}`);
104146
if (!pkg.polarProductId) throw new Error(`Polar product ID not configured for ${packageId}`);
105147

apps/cloud-gateway/src/routes/account.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Hono } from "hono";
66
import { getUserById } from "../lib/users";
77
import { getBalance, getTransactions } from "../lib/credits";
88
import { getOrCreateReferralCode } from "../lib/referrals";
9+
import { sql } from "../lib/db-cloud";
910
import type { CloudEnv } from "../types";
1011

1112
const app = new Hono<CloudEnv>();
@@ -62,4 +63,31 @@ app.get("/referral", async (c) => {
6263
return c.json(referral);
6364
});
6465

66+
// ── GET /announcement — current active announcement (if any) ─────────────────
67+
68+
app.get("/announcement", async (c) => {
69+
const [row] = await sql`
70+
SELECT id, title, message, type, starts_at, ends_at
71+
FROM announcements
72+
WHERE is_active = true
73+
AND starts_at <= now()
74+
AND (ends_at IS NULL OR ends_at > now())
75+
ORDER BY created_at DESC
76+
LIMIT 1
77+
`;
78+
79+
if (!row) return c.json({ announcement: null });
80+
81+
return c.json({
82+
announcement: {
83+
id: row.id,
84+
title: row.title,
85+
message: row.message,
86+
type: row.type,
87+
startsAt: row.starts_at,
88+
endsAt: row.ends_at,
89+
},
90+
});
91+
});
92+
6593
export default app;

0 commit comments

Comments
 (0)