feat(phase1b): sase.tr read-only connection
- prisma/sase/schema.prisma with User + UserSubscription (partial view) - lib/db-sase.ts exports typed RO client - /projects/sase shows live metrics (users count, new users 7d, active subs) - seed marks sase as active DATABASE_URL_SASE_RO wired to super_panel_reader (SELECT-only)
This commit is contained in:
@@ -4,11 +4,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "prisma generate && next build",
|
||||
"build": "prisma generate && prisma generate --schema=./prisma/sase/schema.prisma && next build",
|
||||
"start": "next start -p 3000 -H 0.0.0.0",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:generate": "prisma generate && prisma generate --schema=./prisma/sase/schema.prisma",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
|
||||
42
apps/web/prisma/sase/schema.prisma
Normal file
42
apps/web/prisma/sase/schema.prisma
Normal file
@@ -0,0 +1,42 @@
|
||||
// Partial view of Sase.tr public schema.
|
||||
// Owned by the spoke; panel reads with super_panel_reader (SELECT only).
|
||||
// Add models here only as panel features need them — keep the surface narrow.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../../node_modules/.prisma/client-sase"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL_SASE_RO")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(255)
|
||||
email String @unique @db.VarChar(255)
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
role String @default("user") @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
planId String @map("plan_id") @db.Uuid
|
||||
status String @default("pending") @db.VarChar(20)
|
||||
billingPeriod String @default("monthly") @map("billing_period") @db.VarChar(10)
|
||||
startDate DateTime? @map("start_date") @db.Timestamptz(6)
|
||||
endDate DateTime? @map("end_date") @db.Timestamptz(6)
|
||||
cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { PrismaClient } from "@prisma/client";
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const projects = [
|
||||
{ key: "sase", name: "Sase.tr", description: "VIN/chassis lookup + auto parts catalog", active: false },
|
||||
{ key: "sase", name: "Sase.tr", description: "VIN/chassis lookup + auto parts catalog", active: true },
|
||||
{ key: "catvicer", name: "Catvicer", description: "Multi-tenant catering SaaS", active: false },
|
||||
{ key: "kokpit", name: "Kokpit", description: "Internal ops dashboard", active: false },
|
||||
{ key: "otoyedekparca", name: "otoyedekparca.co", description: "Auto parts marketplace", active: false },
|
||||
|
||||
82
apps/web/src/app/projects/[key]/_sase.tsx
Normal file
82
apps/web/src/app/projects/[key]/_sase.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { saseDb } from "@/lib/db-sase";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
async function getSaseStats() {
|
||||
try {
|
||||
const [users, recentUsers, activeSubs, totalSubs] = await Promise.all([
|
||||
saseDb.user.count(),
|
||||
saseDb.user.count({
|
||||
where: { createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) } },
|
||||
}),
|
||||
saseDb.userSubscription.count({ where: { status: "active" } }),
|
||||
saseDb.userSubscription.count(),
|
||||
]);
|
||||
return { ok: true as const, users, recentUsers, activeSubs, totalSubs };
|
||||
} catch (err) {
|
||||
return { ok: false as const, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function SaseHealth() {
|
||||
const stats = await getSaseStats();
|
||||
|
||||
if (!stats.ok) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Connection</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
<Badge variant="destructive">unreachable</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="font-mono text-xs text-muted-foreground">
|
||||
{stats.error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="Connection" value="healthy" badge="ok" />
|
||||
<Stat label="Total users" value={stats.users.toString()} hint="public.users" />
|
||||
<Stat
|
||||
label="New users (7d)"
|
||||
value={stats.recentUsers.toString()}
|
||||
hint="created_at last 7 days"
|
||||
/>
|
||||
<Stat
|
||||
label="Active subscriptions"
|
||||
value={`${stats.activeSubs} / ${stats.totalSubs}`}
|
||||
hint="status='active'"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
badge,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-2xl font-semibold tabular-nums">
|
||||
{badge ? <Badge>{badge}</Badge> : value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{hint && (
|
||||
<CardContent className="text-xs text-muted-foreground">{hint}</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import { PanelShell } from "@/components/panel-shell";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SaseHealth } from "./_sase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ProjectDetail({ params }: { params: Promise<{ key: string }> }) {
|
||||
const { key } = await params;
|
||||
@@ -21,31 +22,30 @@ export default async function ProjectDetail({ params }: { params: Promise<{ key:
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{project.description}</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Connection</CardDescription>
|
||||
<CardTitle className="text-base">
|
||||
{project.active ? "Read-only DB attached" : "No DB connection configured"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{project.active
|
||||
? "DATABASE_URL is wired. See live metrics below."
|
||||
: "Configure DATABASE_URL_<KEY>_RO in Coolify env."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Admin SDK</CardDescription>
|
||||
<CardTitle className="text-base">0 endpoints wired</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Spoke needs <code>/internal/admin/*</code> endpoints exposed.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{key === "sase" ? (
|
||||
<SaseHealth />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Connection</CardDescription>
|
||||
<CardTitle className="text-base">No DB connection configured</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Configure DATABASE_URL_{key.toUpperCase()}_RO in Coolify env.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Admin SDK</CardDescription>
|
||||
<CardTitle className="text-base">0 endpoints wired</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Spoke needs <code>/internal/admin/*</code> endpoints exposed.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</PanelShell>
|
||||
);
|
||||
}
|
||||
|
||||
11
apps/web/src/lib/db-sase.ts
Normal file
11
apps/web/src/lib/db-sase.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from ".prisma/client-sase";
|
||||
|
||||
const globalForSase = globalThis as unknown as { saseDb?: PrismaClient };
|
||||
|
||||
export const saseDb =
|
||||
globalForSase.saseDb ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForSase.saseDb = saseDb;
|
||||
Reference in New Issue
Block a user