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:
Semih
2026-05-13 10:38:03 +00:00
parent d189206f06
commit f72dcc05ba
6 changed files with 165 additions and 30 deletions

View File

@@ -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"
},

View 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")
}

View File

@@ -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 },

View 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>
);
}

View File

@@ -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>
);
}

View 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;