Compare commits
99 Commits
24812df89b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 723cc5fdd1 | |||
| efb2082400 | |||
| 62cb77b5a7 | |||
| e453e780cc | |||
| 7944db8ba4 | |||
| 5d4936346e | |||
| 040f0c6256 | |||
| f27e572fdb | |||
| 8c8e39d102 | |||
| e09cad409e | |||
| 1f37d4bc91 | |||
| 6d71c63053 | |||
| 8c83f8c490 | |||
| e766315ecd | |||
| d86f2bea03 | |||
| 9959eaeeaa | |||
| fcd5d03894 | |||
| 3192e0f7b9 | |||
| 651ddf1e11 | |||
| d6c87a052e | |||
| de1cd96ec2 | |||
| 62c52747f5 | |||
| b1670c7035 | |||
| eacec74701 | |||
| a591c55fc4 | |||
| 0797717bc1 | |||
| b51fb6da21 | |||
| 14835e2e0a | |||
| 6f79a88abd | |||
| d9d3514647 | |||
| b9511601bc | |||
| 6ccfdee65f | |||
| 0bdf598984 | |||
| 4b2289adfa | |||
| 5fbba46a3d | |||
| acb63a2a5a | |||
| ccc6cc1da5 | |||
| 74f8dc4282 | |||
| bada63452f | |||
| 06238f958a | |||
| 26429f3517 | |||
| a11caafd22 | |||
| 8eb6c149cb | |||
| 062e836ff9 | |||
| d9bea73032 | |||
| 532f851d1f | |||
| f1b4622043 | |||
| f47205c473 | |||
| f9f3156d49 | |||
| 2e3b405893 | |||
| 9e20125938 | |||
| 317abf047b | |||
| 63dded42a6 | |||
| 46efc41812 | |||
| c35e7dbe56 | |||
| cff5cd6014 | |||
| 99c1a83b9f | |||
| 8f95270b12 | |||
| ff0e1592fa | |||
| 1af5595e35 | |||
| e3c6b9a9b4 | |||
| 528d6bb1e3 | |||
| 2aace73e33 | |||
| 6901a97db3 | |||
| 3e9bf7c0e0 | |||
| 0e204ced89 | |||
| 7979fd0518 | |||
| 22f4c4f1c3 | |||
| 5778abe6c3 | |||
| 70c94dc60c | |||
| 57c0744b56 | |||
| aa23a552c4 | |||
| 853e41705f | |||
| 1ef3f9baa3 | |||
| 01848ba682 | |||
| 86f8960aa3 | |||
| 2e0bc95bb0 | |||
| 01c2d33208 | |||
| 65adcd4897 | |||
| 01dd9fda8e | |||
| 9c277fd8e3 | |||
| 231aeb4402 | |||
| fc59333383 | |||
| 7b228ebad2 | |||
| 7f61295637 | |||
| 8c19dc1802 | |||
| 28b48b74af | |||
| f7d38317b2 | |||
| 18f61fe95c | |||
| 61a43ad9b4 | |||
| ad3abd427b | |||
| 93a2b4a0ac | |||
| 3cd477c295 | |||
| 3770ba1853 | |||
| 39167dbe45 | |||
| 812645cae8 | |||
| e08fcf674b | |||
| bb021be088 | |||
| ab100f2e76 |
61
.env.example
Normal file
61
.env.example
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Copy to Coolify environment variables (or .env.local for dev). Do not commit secrets.
|
||||||
|
|
||||||
|
# --- Postgres: local `next dev` (Coolify internal hostnames do NOT work on your laptop) ---
|
||||||
|
# npm run db:local:up then npm run db:local:push with:
|
||||||
|
# DATABASE_URL=postgresql://vibn:vibn@localhost:5433/vibn
|
||||||
|
# POSTGRES_URL=postgresql://vibn:vibn@localhost:5433/vibn
|
||||||
|
|
||||||
|
# --- Postgres: production / Coolify (from Coolify UI, reachable from where the app runs) ---
|
||||||
|
# Coolify: open the Postgres service → expose/publish a host port → use SERVER_IP:HOST_PORT (not internal UUID host).
|
||||||
|
# From repo root, master-ai/.coolify.env with COOLIFY_URL + COOLIFY_API_TOKEN: npm run db:sync:coolify
|
||||||
|
# Example shape: postgresql://USER:PASSWORD@34.19.250.135:YOUR_PUBLISHED_PORT/vibn
|
||||||
|
# External/cloud: set DB_SSL=true if the DB requires TLS.
|
||||||
|
DATABASE_URL=
|
||||||
|
POSTGRES_URL=
|
||||||
|
|
||||||
|
# --- Public URL of this Next app (OAuth callbacks, runner callbacks) ---
|
||||||
|
# Local Google OAuth (must match the host/port you open in the browser):
|
||||||
|
# NEXTAUTH_URL=http://localhost:3000
|
||||||
|
# Google Cloud Console → OAuth client → Authorized redirect URIs (exact):
|
||||||
|
# http://localhost:3000/api/auth/callback/google
|
||||||
|
# If you use 127.0.0.1 or another port, use that consistently everywhere.
|
||||||
|
# Prisma adapter needs Postgres + tables: set DATABASE_URL then run: npx prisma db push
|
||||||
|
NEXTAUTH_URL=https://vibnai.com
|
||||||
|
NEXTAUTH_SECRET=
|
||||||
|
|
||||||
|
# --- vibn-agent-runner (same Docker network: http://<service-name>:3333 — or public https://agents.vibnai.com) ---
|
||||||
|
AGENT_RUNNER_URL=http://localhost:3333
|
||||||
|
|
||||||
|
# --- Shared secret: must match runner. Required for PATCH session + POST /events ingest ---
|
||||||
|
AGENT_RUNNER_SECRET=
|
||||||
|
|
||||||
|
# --- Optional: one-shot DDL via POST /api/admin/migrate ---
|
||||||
|
# ADMIN_MIGRATE_SECRET=
|
||||||
|
|
||||||
|
# --- Gitea (git.vibnai.com) — admin token used to create per-workspace orgs/repos ---
|
||||||
|
# Token must have admin scope to create orgs. Per-workspace repos are created
|
||||||
|
# under "vibn-{workspace-slug}" orgs; legacy projects remain under GITEA_ADMIN_USER.
|
||||||
|
GITEA_API_URL=https://git.vibnai.com
|
||||||
|
GITEA_API_TOKEN=
|
||||||
|
GITEA_ADMIN_USER=mark
|
||||||
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# --- Coolify (coolify.vibnai.com) — admin token used to create per-workspace Projects ---
|
||||||
|
# Each Vibn workspace gets one Coolify Project (named "vibn-ws-{slug}") that
|
||||||
|
# acts as the tenant boundary. All apps + DBs for that workspace live there.
|
||||||
|
COOLIFY_URL=https://coolify.vibnai.com
|
||||||
|
COOLIFY_API_TOKEN=
|
||||||
|
COOLIFY_SERVER_UUID=jws4g4cgssss4cw48s488woc
|
||||||
|
|
||||||
|
# --- Google OAuth / Gemini (see .google.env locally) ---
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Local dev: skip Google (next dev only) ---
|
||||||
|
# NEXT_PUBLIC_DEV_LOCAL_AUTH_EMAIL=you@example.com
|
||||||
|
# Skip NextAuth session for API + project UI (same email must own rows in fs_users)
|
||||||
|
# NEXT_PUBLIC_DEV_BYPASS_PROJECT_AUTH=true
|
||||||
|
# Optional: require password for dev-local provider (omit to allow localhost Host only)
|
||||||
|
# DEV_LOCAL_AUTH_SECRET=
|
||||||
|
# Optional display name for the dev user row
|
||||||
|
# DEV_LOCAL_AUTH_NAME=Local dev
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
|||||||
COPY --from=builder /app/node_modules/@next-auth ./node_modules/@next-auth
|
COPY --from=builder /app/node_modules/@next-auth ./node_modules/@next-auth
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
|
||||||
|
# Scaffold templates are read at runtime via fs — must be in the runner image
|
||||||
|
COPY --from=builder /app/lib/scaffold ./lib/scaffold
|
||||||
|
|
||||||
# Copy and set up entrypoint
|
# Copy and set up entrypoint
|
||||||
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function FeaturesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container py-8 md:py-12 lg:py-24">
|
<div className="container py-8 md:py-12 lg:py-24">
|
||||||
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
<div className="mx-auto flex max-w-[980px] flex-col items-center gap-4">
|
||||||
<h1 className="text-4xl font-extrabold leading-tight tracking-tighter md:text-6xl lg:leading-[1.1]">
|
<h1 className="font-serif text-4xl font-bold leading-tight tracking-tight md:text-6xl lg:leading-[1.1]">
|
||||||
Powerful Features for AI Developers
|
Powerful Features for AI Developers
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
<p className="max-w-[750px] text-center text-lg text-muted-foreground">
|
||||||
@@ -30,7 +30,7 @@ export default function FeaturesPage() {
|
|||||||
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
<div className="mx-auto grid max-w-6xl grid-cols-1 gap-6 pt-12 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Code2 className="h-12 w-12 text-blue-600" />
|
<Code2 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Automatic Session Tracking</CardTitle>
|
<CardTitle>Automatic Session Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Every coding session is automatically captured with zero configuration.
|
Every coding session is automatically captured with zero configuration.
|
||||||
@@ -48,7 +48,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Brain className="h-12 w-12 text-purple-600" />
|
<Brain className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>AI Usage Analytics</CardTitle>
|
<CardTitle>AI Usage Analytics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Deep insights into how you and your team use AI tools.
|
Deep insights into how you and your team use AI tools.
|
||||||
@@ -66,7 +66,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<DollarSign className="h-12 w-12 text-green-600" />
|
<DollarSign className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Cost Tracking</CardTitle>
|
<CardTitle>Cost Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Real-time cost monitoring for all your AI services.
|
Real-time cost monitoring for all your AI services.
|
||||||
@@ -84,7 +84,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Clock className="h-12 w-12 text-orange-600" />
|
<Clock className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Productivity Metrics</CardTitle>
|
<CardTitle>Productivity Metrics</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Track your velocity and identify productivity patterns.
|
Track your velocity and identify productivity patterns.
|
||||||
@@ -102,7 +102,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Github className="h-12 w-12 text-gray-600" />
|
<Github className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>GitHub Integration</CardTitle>
|
<CardTitle>GitHub Integration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Connect your repositories for comprehensive code analysis.
|
Connect your repositories for comprehensive code analysis.
|
||||||
@@ -120,7 +120,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Sparkles className="h-12 w-12 text-pink-600" />
|
<Sparkles className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Smart Summaries</CardTitle>
|
<CardTitle>Smart Summaries</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
AI-powered summaries of your work and progress.
|
AI-powered summaries of your work and progress.
|
||||||
@@ -138,7 +138,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Users className="h-12 w-12 text-cyan-600" />
|
<Users className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Team Collaboration</CardTitle>
|
<CardTitle>Team Collaboration</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Built for teams working with AI tools together.
|
Built for teams working with AI tools together.
|
||||||
@@ -156,7 +156,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<FileCode className="h-12 w-12 text-indigo-600" />
|
<FileCode className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Code Quality Tracking</CardTitle>
|
<CardTitle>Code Quality Tracking</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Monitor code quality and AI-generated code effectiveness.
|
Monitor code quality and AI-generated code effectiveness.
|
||||||
@@ -174,7 +174,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<TrendingUp className="h-12 w-12 text-emerald-600" />
|
<TrendingUp className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Trend Analysis</CardTitle>
|
<CardTitle>Trend Analysis</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Understand long-term patterns in your development process.
|
Understand long-term patterns in your development process.
|
||||||
@@ -192,7 +192,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Shield className="h-12 w-12 text-red-600" />
|
<Shield className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Privacy & Security</CardTitle>
|
<CardTitle>Privacy & Security</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your code and data stay private and secure.
|
Your code and data stay private and secure.
|
||||||
@@ -210,7 +210,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Zap className="h-12 w-12 text-yellow-600" />
|
<Zap className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Real-Time Insights</CardTitle>
|
<CardTitle>Real-Time Insights</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Get instant feedback as you code.
|
Get instant feedback as you code.
|
||||||
@@ -228,7 +228,7 @@ export default function FeaturesPage() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<BarChart3 className="h-12 w-12 text-violet-600" />
|
<BarChart3 className="h-12 w-12 text-primary" />
|
||||||
<CardTitle>Custom Reports</CardTitle>
|
<CardTitle>Custom Reports</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Create custom reports tailored to your needs.
|
Create custom reports tailored to your needs.
|
||||||
47
app/(justine)/layout.tsx
Normal file
47
app/(justine)/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
|
import { homepage } from "@/marketing/content/homepage";
|
||||||
|
import { JustineNav } from "@/marketing/components/justine/JustineNav";
|
||||||
|
import { JustineFooter } from "@/marketing/components/justine/JustineFooter";
|
||||||
|
import "../styles/justine/01-homepage.css";
|
||||||
|
|
||||||
|
const justineJakarta = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700", "800"],
|
||||||
|
variable: "--font-justine-jakarta",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
openGraph: {
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
url: "https://www.vibnai.com",
|
||||||
|
siteName: "VIBN",
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: homepage.meta.title,
|
||||||
|
description: homepage.meta.description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function JustineLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-justine
|
||||||
|
className={`${justineJakarta.variable} flex min-h-screen flex-col`}
|
||||||
|
>
|
||||||
|
<JustineNav />
|
||||||
|
<main>{children}</main>
|
||||||
|
<JustineFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(justine)/page.tsx
Normal file
5
app/(justine)/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { JustineHomePage } from "@/marketing/components/justine/JustineHomePage";
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
return <JustineHomePage />;
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ export default function PricingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Pro Tier */}
|
{/* Pro Tier */}
|
||||||
<Card className="border-primary shadow-lg">
|
<Card className="relative border-primary shadow-lg">
|
||||||
<div className="absolute right-4 top-4 rounded-full bg-primary px-3 py-1 text-xs text-primary-foreground">
|
<div className="absolute right-4 top-4 rounded-full bg-primary px-3 py-1 text-xs text-primary-foreground">
|
||||||
Popular
|
Popular
|
||||||
</div>
|
</div>
|
||||||
1
app/(justine)/stories/page.tsx
Normal file
1
app/(justine)/stories/page.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../features/page";
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { homepage } from "@/marketing/content/homepage";
|
|
||||||
import { Footer } from "@/marketing/components";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
openGraph: {
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
url: "https://www.vibnai.com",
|
|
||||||
siteName: "VIBN",
|
|
||||||
type: "website",
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
card: "summary_large_image",
|
|
||||||
title: homepage.meta.title,
|
|
||||||
description: homepage.meta.description,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MarketingLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col">
|
|
||||||
{/* Navigation */}
|
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
||||||
<div className="container flex h-16 items-center">
|
|
||||||
<div className="flex gap-6 md:gap-10">
|
|
||||||
<Link href="/" className="flex items-center space-x-2">
|
|
||||||
<img
|
|
||||||
src="/vibn-black-circle-logo.png"
|
|
||||||
alt="Vib'n"
|
|
||||||
className="h-8 w-8"
|
|
||||||
/>
|
|
||||||
<span className="text-xl font-bold">Vib'n</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
|
||||||
<nav className="flex items-center space-x-6">
|
|
||||||
<Link
|
|
||||||
href="/#features"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Features
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/#how-it-works"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
How It Works
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/#pricing"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://github.com/MawkOne/viben"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-medium transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<Link href="/auth">
|
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Link href="/auth">
|
|
||||||
<Button size="sm">Get Started</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="flex-1 w-full">{children}</main>
|
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import {
|
|
||||||
Hero,
|
|
||||||
EmotionalHook,
|
|
||||||
WhoItsFor,
|
|
||||||
Transformation,
|
|
||||||
Features,
|
|
||||||
HowItWorks,
|
|
||||||
Pricing,
|
|
||||||
CTA,
|
|
||||||
} from "@/marketing/components";
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Hero />
|
|
||||||
<EmotionalHook />
|
|
||||||
<WhoItsFor />
|
|
||||||
<Transformation />
|
|
||||||
<Features />
|
|
||||||
<HowItWorks />
|
|
||||||
<Pricing />
|
|
||||||
<CTA />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ function typeColor(t: string) {
|
|||||||
|
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "atlas", label: "Atlas" },
|
{ id: "atlas", label: "Vibn" },
|
||||||
{ id: "build", label: "Builds" },
|
{ id: "build", label: "Builds" },
|
||||||
{ id: "deploy", label: "Deploys" },
|
{ id: "deploy", label: "Deploys" },
|
||||||
{ id: "user", label: "You" },
|
{ id: "user", label: "You" },
|
||||||
@@ -58,10 +58,10 @@ export default function ActivityPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "44px 52px", maxWidth: 720, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.9rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
|
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em", marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
Activity
|
Activity
|
||||||
@@ -81,7 +81,7 @@ export default function ActivityPage() {
|
|||||||
background: filter === f.id ? "#1a1a1a" : "#fff",
|
background: filter === f.id ? "#1a1a1a" : "#fff",
|
||||||
color: filter === f.id ? "#fff" : "#6b6560",
|
color: filter === f.id ? "#fff" : "#6b6560",
|
||||||
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
|
fontSize: "0.75rem", fontWeight: 600, transition: "all 0.12s",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
|
|||||||
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
34
app/[workspace]/project/[projectId]/(home)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project home scaffold.
|
||||||
|
*
|
||||||
|
* Mirrors the /[workspace]/projects scaffold: VIBNSidebar on the left,
|
||||||
|
* cream main area on the right. Used only for the project home page
|
||||||
|
* (`/{workspace}/project/{id}`) — sub-routes use the (workspace) group
|
||||||
|
* with the ProjectShell tab nav instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { VIBNSidebar } from "@/components/layout/vibn-sidebar";
|
||||||
|
import { ProjectAssociationPrompt } from "@/components/project-association-prompt";
|
||||||
|
|
||||||
|
export default function ProjectHomeLayout({ children }: { children: ReactNode }) {
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||||
|
<VIBNSidebar workspace={workspace} />
|
||||||
|
<main style={{ flex: 1, overflow: "auto" }}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<ProjectAssociationPrompt workspace={workspace} />
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
696
app/[workspace]/project/[projectId]/(home)/page.tsx
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project home page.
|
||||||
|
*
|
||||||
|
* Sits between the projects list and the AI interview. Gives users two
|
||||||
|
* simplified entry tiles — Code (their Gitea repo) and Infrastructure
|
||||||
|
* (their Coolify deployment) — plus a quiet "Continue setup" link if
|
||||||
|
* the discovery interview isn't done.
|
||||||
|
*
|
||||||
|
* Styled to match the production "ink & parchment" design:
|
||||||
|
* Newsreader serif headings, Outfit sans body, warm beige borders,
|
||||||
|
* solid black CTAs. No indigo. No gradients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Code2,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Folder,
|
||||||
|
Loader2,
|
||||||
|
Rocket,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
// ── Design tokens (mirrors the prod ink & parchment palette) ─────────
|
||||||
|
const INK = {
|
||||||
|
fontSerif: '"Newsreader", "Lora", Georgia, serif',
|
||||||
|
fontSans: '"Outfit", "Inter", ui-sans-serif, sans-serif',
|
||||||
|
fontMono: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||||
|
ink: "#1a1a1a",
|
||||||
|
ink2: "#2c2c2a",
|
||||||
|
mid: "#5f5e5a",
|
||||||
|
muted: "#a09a90",
|
||||||
|
stone: "#b5b0a6",
|
||||||
|
border: "#e8e4dc",
|
||||||
|
borderHover: "#d0ccc4",
|
||||||
|
cardBg: "#fff",
|
||||||
|
pageBg: "#f7f4ee",
|
||||||
|
shadow: "0 1px 2px #1a1a1a05",
|
||||||
|
shadowHover: "0 2px 8px #1a1a1a0a",
|
||||||
|
iconWrapBg: "#1a1a1a08",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface ProjectSummary {
|
||||||
|
id: string;
|
||||||
|
productName?: string;
|
||||||
|
name?: string;
|
||||||
|
productVision?: string;
|
||||||
|
description?: string;
|
||||||
|
giteaRepo?: string;
|
||||||
|
giteaRepoUrl?: string;
|
||||||
|
stage?: "discovery" | "architecture" | "building" | "active";
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
discoveryPhase?: number;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileTreeItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "dir";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewApp {
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectHomePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const { status: authStatus } = useSession();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectSummary | null>(null);
|
||||||
|
const [projectLoading, setProjectLoading] = useState(true);
|
||||||
|
|
||||||
|
const [files, setFiles] = useState<FileTreeItem[] | null>(null);
|
||||||
|
const [filesLoading, setFilesLoading] = useState(true);
|
||||||
|
|
||||||
|
const [apps, setApps] = useState<PreviewApp[]>([]);
|
||||||
|
const [appsLoading, setAppsLoading] = useState(true);
|
||||||
|
|
||||||
|
const ready = useMemo(
|
||||||
|
() => isClientDevProjectBypass() || authStatus === "authenticated",
|
||||||
|
[authStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) {
|
||||||
|
if (authStatus === "unauthenticated") setProjectLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/projects/${projectId}`, { credentials: "include" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject(d.project ?? null))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setProjectLoading(false));
|
||||||
|
}, [ready, authStatus, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
fetch(`/api/projects/${projectId}/file?path=`, { credentials: "include" })
|
||||||
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
.then(d => {
|
||||||
|
if (d?.type === "dir" && Array.isArray(d.items)) {
|
||||||
|
setFiles(d.items as FileTreeItem[]);
|
||||||
|
} else {
|
||||||
|
setFiles([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setFiles([]))
|
||||||
|
.finally(() => setFilesLoading(false));
|
||||||
|
}, [ready, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
fetch(`/api/projects/${projectId}/preview-url`, { credentials: "include" })
|
||||||
|
.then(r => (r.ok ? r.json() : null))
|
||||||
|
.then(d => setApps(Array.isArray(d?.apps) ? d.apps : []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setAppsLoading(false));
|
||||||
|
}, [ready, projectId]);
|
||||||
|
|
||||||
|
const projectName = project?.productName || project?.name || "Untitled project";
|
||||||
|
const projectDesc = project?.productVision || project?.description;
|
||||||
|
const stage = project?.stage ?? "discovery";
|
||||||
|
const interviewIncomplete = stage === "discovery";
|
||||||
|
const liveApp = apps.find(a => a.url) ?? apps[0] ?? null;
|
||||||
|
|
||||||
|
if (projectLoading) {
|
||||||
|
return (
|
||||||
|
<div style={pageWrap}>
|
||||||
|
<div style={centeredFiller}>
|
||||||
|
<Loader2 className="animate-spin" size={22} style={{ color: INK.stone }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div style={pageWrap}>
|
||||||
|
<div style={{ ...centeredFiller, color: INK.muted, fontSize: 14, fontFamily: INK.fontSans }}>
|
||||||
|
Project not found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={pageWrap}>
|
||||||
|
<div style={pageInner}>
|
||||||
|
{/* ── Hero ─────────────────────────────────────────────── */}
|
||||||
|
<header style={heroStyle}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={eyebrow}>Project</div>
|
||||||
|
<h1 style={heroTitle}>{projectName}</h1>
|
||||||
|
{projectDesc && <p style={heroDesc}>{projectDesc}</p>}
|
||||||
|
</div>
|
||||||
|
<StagePill stage={stage} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Continue setup link (quiet, only when in discovery) ── */}
|
||||||
|
{interviewIncomplete && (
|
||||||
|
<Link
|
||||||
|
href={`/${workspace}/project/${projectId}/overview`}
|
||||||
|
style={continueRow}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
|
<span style={continueDot} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={continueTitle}>Continue setup</div>
|
||||||
|
<div style={continueSub}>
|
||||||
|
Pick up the AI interview where you left off.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={16} style={{ color: INK.ink, flexShrink: 0 }} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Two big tiles ────────────────────────────────────── */}
|
||||||
|
<div style={tileGrid}>
|
||||||
|
<CodeTile
|
||||||
|
workspace={workspace}
|
||||||
|
projectId={projectId}
|
||||||
|
files={files}
|
||||||
|
loading={filesLoading}
|
||||||
|
giteaRepo={project.giteaRepo}
|
||||||
|
/>
|
||||||
|
<InfraTile
|
||||||
|
workspace={workspace}
|
||||||
|
projectId={projectId}
|
||||||
|
app={liveApp}
|
||||||
|
loading={appsLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Tiles
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CodeTile({
|
||||||
|
workspace,
|
||||||
|
projectId,
|
||||||
|
files,
|
||||||
|
loading,
|
||||||
|
giteaRepo,
|
||||||
|
}: {
|
||||||
|
workspace: string;
|
||||||
|
projectId: string;
|
||||||
|
files: FileTreeItem[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
giteaRepo?: string;
|
||||||
|
}) {
|
||||||
|
const items = files ?? [];
|
||||||
|
const dirCount = items.filter(i => i.type === "dir").length;
|
||||||
|
const fileCount = items.filter(i => i.type === "file").length;
|
||||||
|
const previewItems = items.slice(0, 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspace}/project/${projectId}/code`} style={tileLink}>
|
||||||
|
<article
|
||||||
|
style={tileCard}
|
||||||
|
onMouseEnter={hoverEnter}
|
||||||
|
onMouseLeave={hoverLeave}
|
||||||
|
>
|
||||||
|
<header style={tileHeader}>
|
||||||
|
<span style={tileIconWrap}>
|
||||||
|
<Code2 size={16} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h2 style={tileTitle}>Code</h2>
|
||||||
|
<p style={tileSubtitle}>What the AI is building, file by file.</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={tileBody}>
|
||||||
|
{loading ? (
|
||||||
|
<TileLoader label="Reading repository…" />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<TileEmpty
|
||||||
|
icon={<Folder size={18} />}
|
||||||
|
title="No files yet"
|
||||||
|
subtitle={
|
||||||
|
giteaRepo
|
||||||
|
? "Your repository is empty. The AI will commit the first files when you start building."
|
||||||
|
: "This project doesn't have a repository yet."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={tileMetaRow}>
|
||||||
|
<Metric label="Folders" value={dirCount} />
|
||||||
|
<Metric label="Files" value={fileCount} />
|
||||||
|
</div>
|
||||||
|
<ul style={fileList}>
|
||||||
|
{previewItems.map(item => (
|
||||||
|
<li key={item.path} style={fileRow}>
|
||||||
|
<span style={fileIconWrap}>
|
||||||
|
{item.type === "dir" ? (
|
||||||
|
<Folder size={13} />
|
||||||
|
) : (
|
||||||
|
<FileText size={13} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={fileName}>{item.name}</span>
|
||||||
|
<span style={fileType}>
|
||||||
|
{item.type === "dir" ? "folder" : ext(item.name)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{items.length > previewItems.length && (
|
||||||
|
<div style={tileMore}>
|
||||||
|
+{items.length - previewItems.length} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfraTile({
|
||||||
|
workspace,
|
||||||
|
projectId,
|
||||||
|
app,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
workspace: string;
|
||||||
|
projectId: string;
|
||||||
|
app: PreviewApp | null;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const status = app?.status?.toLowerCase() ?? "unknown";
|
||||||
|
const isLive = !!app?.url && (status.includes("running") || status.includes("healthy"));
|
||||||
|
const isBuilding = status.includes("queued") || status.includes("in_progress") || status.includes("starting");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspace}/project/${projectId}/infrastructure`} style={tileLink}>
|
||||||
|
<article
|
||||||
|
style={tileCard}
|
||||||
|
onMouseEnter={hoverEnter}
|
||||||
|
onMouseLeave={hoverLeave}
|
||||||
|
>
|
||||||
|
<header style={tileHeader}>
|
||||||
|
<span style={tileIconWrap}>
|
||||||
|
<Rocket size={16} />
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h2 style={tileTitle}>Infrastructure</h2>
|
||||||
|
<p style={tileSubtitle}>What's live and how it's running.</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} style={{ color: INK.muted }} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={tileBody}>
|
||||||
|
{loading ? (
|
||||||
|
<TileLoader label="Checking deployment…" />
|
||||||
|
) : !app ? (
|
||||||
|
<TileEmpty
|
||||||
|
icon={<Rocket size={18} />}
|
||||||
|
title="Nothing is live yet"
|
||||||
|
subtitle="The AI will deploy your project here once the build is ready."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={tileMetaRow}>
|
||||||
|
<StatusBlock
|
||||||
|
color={isLive ? "#2e7d32" : isBuilding ? "#3d5afe" : "#9a7b3a"}
|
||||||
|
label={isLive ? "Live" : isBuilding ? "Building" : statusFriendly(status)}
|
||||||
|
/>
|
||||||
|
<Metric label="App" value={app.name} />
|
||||||
|
</div>
|
||||||
|
{app.url ? (
|
||||||
|
<div style={liveUrlRow}>
|
||||||
|
<span style={liveUrlLabel}>Live URL</span>
|
||||||
|
<span style={liveUrlValue}>{shortUrl(app.url)}</span>
|
||||||
|
<a
|
||||||
|
href={app.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={liveUrlOpen}
|
||||||
|
aria-label="Open live site"
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={liveUrlRow}>
|
||||||
|
<span style={liveUrlLabel}>Status</span>
|
||||||
|
<span style={liveUrlValue}>{statusFriendly(status)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Small bits
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StagePill({ stage }: { stage: string }) {
|
||||||
|
const map: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
discovery: { label: "Defining", color: "#9a7b3a", bg: "#d4a04a12" },
|
||||||
|
architecture: { label: "Planning", color: "#3d5afe", bg: "#3d5afe10" },
|
||||||
|
building: { label: "Building", color: "#3d5afe", bg: "#3d5afe10" },
|
||||||
|
active: { label: "Live", color: "#2e7d32", bg: "#2e7d3210" },
|
||||||
|
};
|
||||||
|
const s = map[stage] ?? map.discovery;
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 6,
|
||||||
|
padding: "3px 9px", borderRadius: 4,
|
||||||
|
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||||
|
color: s.color, background: s.bg, fontFamily: INK.fontSans,
|
||||||
|
whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.color }} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBlock({ color, label }: { color: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
<span style={metricLabel}>Status</span>
|
||||||
|
<span style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 6,
|
||||||
|
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: color }} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||||
|
<span style={metricLabel}>{label}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13, color: INK.ink, fontFamily: INK.fontSans, fontWeight: 500,
|
||||||
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
}}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TileLoader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
gap: 8, padding: "32px 0", color: INK.muted, fontSize: 13,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
}}>
|
||||||
|
<Loader2 className="animate-spin" size={14} /> {label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TileEmpty({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: "28px 8px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
}}>
|
||||||
|
<span style={{ ...tileIconWrap, width: 38, height: 38 }}>{icon}</span>
|
||||||
|
<div style={{ fontSize: 13.5, fontWeight: 600, color: INK.ink }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: INK.muted, lineHeight: 1.55, maxWidth: 280 }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFriendly(status: string): string {
|
||||||
|
if (!status || status === "unknown") return "Unknown";
|
||||||
|
return status.replace(/[:_-]+/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ext(name: string): string {
|
||||||
|
const dot = name.lastIndexOf(".");
|
||||||
|
return dot > 0 ? name.slice(dot + 1) : "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.host + (u.pathname === "/" ? "" : u.pathname);
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoverEnter(e: React.MouseEvent<HTMLElement>) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.borderColor = INK.borderHover;
|
||||||
|
el.style.boxShadow = INK.shadowHover;
|
||||||
|
}
|
||||||
|
function hoverLeave(e: React.MouseEvent<HTMLElement>) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.borderColor = INK.border;
|
||||||
|
el.style.boxShadow = INK.shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// Styles
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const pageWrap: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "auto",
|
||||||
|
background: INK.pageBg,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageInner: React.CSSProperties = {
|
||||||
|
maxWidth: 900,
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "44px 52px 64px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
const centeredFiller: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
height: "100%", padding: 64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroStyle: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "flex-start", justifyContent: "space-between",
|
||||||
|
gap: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
const eyebrow: React.CSSProperties = {
|
||||||
|
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase", color: INK.muted,
|
||||||
|
fontFamily: INK.fontSans, marginBottom: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroTitle: React.CSSProperties = {
|
||||||
|
fontFamily: INK.fontSerif,
|
||||||
|
fontSize: "1.9rem", fontWeight: 400,
|
||||||
|
color: INK.ink, letterSpacing: "-0.03em",
|
||||||
|
lineHeight: 1.15, margin: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const heroDesc: React.CSSProperties = {
|
||||||
|
fontSize: "0.88rem", color: INK.mid, marginTop: 10, maxWidth: 620,
|
||||||
|
lineHeight: 1.6, fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueRow: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16,
|
||||||
|
background: INK.cardBg, border: `1px solid ${INK.border}`,
|
||||||
|
borderRadius: 10, padding: "14px 18px",
|
||||||
|
textDecoration: "none", color: "inherit",
|
||||||
|
boxShadow: INK.shadow,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueDot: React.CSSProperties = {
|
||||||
|
width: 7, height: 7, borderRadius: "50%",
|
||||||
|
background: "#d4a04a", flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueTitle: React.CSSProperties = {
|
||||||
|
fontSize: 13, fontWeight: 600, color: INK.ink,
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueSub: React.CSSProperties = {
|
||||||
|
fontSize: 12, color: INK.muted, marginTop: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileGrid: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
|
||||||
|
gap: 14,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileLink: React.CSSProperties = {
|
||||||
|
textDecoration: "none", color: "inherit",
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileCard: React.CSSProperties = {
|
||||||
|
background: INK.cardBg,
|
||||||
|
border: `1px solid ${INK.border}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 22,
|
||||||
|
display: "flex", flexDirection: "column", gap: 18,
|
||||||
|
minHeight: 280,
|
||||||
|
boxShadow: INK.shadow,
|
||||||
|
transition: "border-color 0.15s, box-shadow 0.15s",
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileHeader: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileIconWrap: React.CSSProperties = {
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: INK.iconWrapBg, color: INK.ink,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileTitle: React.CSSProperties = {
|
||||||
|
fontFamily: INK.fontSerif,
|
||||||
|
fontSize: "1.05rem", fontWeight: 400,
|
||||||
|
color: INK.ink, letterSpacing: "-0.02em",
|
||||||
|
margin: 0, lineHeight: 1.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileSubtitle: React.CSSProperties = {
|
||||||
|
fontSize: 12, color: INK.muted, marginTop: 3,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileBody: React.CSSProperties = {
|
||||||
|
display: "flex", flexDirection: "column", gap: 14, flex: 1, minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileMetaRow: React.CSSProperties = {
|
||||||
|
display: "flex", gap: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricLabel: React.CSSProperties = {
|
||||||
|
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase", color: INK.muted,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileList: React.CSSProperties = {
|
||||||
|
listStyle: "none", padding: 0, margin: 0,
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
border: `1px solid ${INK.border}`, borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#fdfcfa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileRow: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderTop: `1px solid ${INK.border}`,
|
||||||
|
fontSize: 12.5, color: INK.ink,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileIconWrap: React.CSSProperties = {
|
||||||
|
color: INK.stone, display: "flex", alignItems: "center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName: React.CSSProperties = {
|
||||||
|
flex: 1, minWidth: 0, overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
fontFamily: INK.fontMono, fontSize: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileType: React.CSSProperties = {
|
||||||
|
fontSize: 10, color: INK.stone, fontWeight: 500,
|
||||||
|
textTransform: "uppercase", letterSpacing: "0.08em",
|
||||||
|
flexShrink: 0, fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tileMore: React.CSSProperties = {
|
||||||
|
fontSize: 11.5, color: INK.muted, paddingLeft: 4,
|
||||||
|
fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveUrlRow: React.CSSProperties = {
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#fdfcfa",
|
||||||
|
border: `1px solid ${INK.border}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveUrlLabel: React.CSSProperties = {
|
||||||
|
fontSize: "0.62rem", fontWeight: 600, letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase", color: INK.muted,
|
||||||
|
flexShrink: 0, fontFamily: INK.fontSans,
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveUrlValue: React.CSSProperties = {
|
||||||
|
flex: 1, minWidth: 0,
|
||||||
|
fontSize: 12, color: INK.ink, fontWeight: 500,
|
||||||
|
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
||||||
|
fontFamily: INK.fontMono,
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveUrlOpen: React.CSSProperties = {
|
||||||
|
width: 24, height: 24, borderRadius: 6,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
color: INK.ink, background: INK.cardBg,
|
||||||
|
border: `1px solid ${INK.border}`, flexShrink: 0,
|
||||||
|
textDecoration: "none",
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "customers",
|
||||||
|
label: "Customers",
|
||||||
|
icon: "◉",
|
||||||
|
title: "Customer List",
|
||||||
|
desc: "Every user who has signed up, their plan, usage, last seen, and lifecycle stage. Filter, search, and act on any segment.",
|
||||||
|
items: ["User Directory", "Lifecycle Stages", "Plan & Billing", "Activity Timeline", "Segment Builder"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "usage",
|
||||||
|
label: "Usage",
|
||||||
|
icon: "∿",
|
||||||
|
title: "Usage & Activity",
|
||||||
|
desc: "How users interact with your product — feature adoption, session frequency, retention curves, and activation funnels.",
|
||||||
|
items: ["Feature Adoption", "Session Metrics", "Retention Curves", "Activation Funnel", "Power Users"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "events",
|
||||||
|
label: "Events",
|
||||||
|
icon: "◬",
|
||||||
|
title: "Events & Tracking",
|
||||||
|
desc: "Every event your product fires — page views, clicks, conversions, and custom events — all tagged and queryable.",
|
||||||
|
items: ["Event Stream", "Custom Events", "Page Views", "Conversion Events", "Tag Manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reports",
|
||||||
|
label: "Reports",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Reports",
|
||||||
|
desc: "MRR, churn, DAU/MAU, cohort analysis, and revenue reports. Export or share with your team on a schedule.",
|
||||||
|
items: ["Revenue (MRR/ARR)", "Churn Report", "DAU / MAU", "Cohort Analysis", "Custom Reports", "Scheduled Exports"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AnalyticsInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "customers") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/analytics?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Analytics</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<AnalyticsInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
133
app/[workspace]/project/[projectId]/(workspace)/assist/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "emails",
|
||||||
|
label: "Emails",
|
||||||
|
icon: "◈",
|
||||||
|
title: "Email",
|
||||||
|
desc: "Transactional and support emails — onboarding sequences, password resets, billing receipts, and support replies — all in one place.",
|
||||||
|
items: ["Onboarding Sequence", "Transactional Emails", "Support Replies", "Billing Notices", "Digests & Summaries"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat",
|
||||||
|
label: "Chat Support",
|
||||||
|
icon: "◎",
|
||||||
|
title: "Chat Support",
|
||||||
|
desc: "Live chat and AI-powered support widget embedded in your product. Routes to human agents when needed, logs every conversation.",
|
||||||
|
items: ["Live Chat Widget", "AI First Response", "Agent Handoff", "Conversation History", "Canned Responses"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support-site",
|
||||||
|
label: "Support Site",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Support Site",
|
||||||
|
desc: "Your public help centre — searchable docs, FAQs, guides, and tutorials. Deflects support tickets before they're created.",
|
||||||
|
items: ["Help Articles", "FAQs", "Video Guides", "Release Notes", "Status Page"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "communications",
|
||||||
|
label: "Communications",
|
||||||
|
icon: "↗",
|
||||||
|
title: "In-App Communications",
|
||||||
|
desc: "Announcements, tooltips, banners, and nudges shown directly inside your product to guide and inform users.",
|
||||||
|
items: ["In-App Banners", "Tooltips & Tours", "Feature Announcements", "NPS Surveys", "Feedback Prompts"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AssistInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "emails") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/assist?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Assist</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>{active.title} is coming to VIBN</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>We're building this section next. Shape it by telling us what you need.</div>
|
||||||
|
</div>
|
||||||
|
<button style={{ background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8, padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0 }}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssistPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<AssistInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
1626
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
1626
app/[workspace]/project/[projectId]/(workspace)/build/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ interface Project {
|
|||||||
status?: string;
|
status?: string;
|
||||||
giteaRepoUrl?: string;
|
giteaRepoUrl?: string;
|
||||||
giteaRepo?: string;
|
giteaRepo?: string;
|
||||||
theiaWorkspaceUrl?: string;
|
|
||||||
coolifyDeployUrl?: string;
|
coolifyDeployUrl?: string;
|
||||||
customDomain?: string;
|
customDomain?: string;
|
||||||
prd?: string;
|
prd?: string;
|
||||||
@@ -64,24 +63,24 @@ export default function DeploymentPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||||
Loading…
|
Loading…
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasDeploy = Boolean(project?.coolifyDeployUrl || project?.theiaWorkspaceUrl);
|
const hasDeploy = Boolean(project?.coolifyDeployUrl);
|
||||||
const hasRepo = Boolean(project?.giteaRepoUrl);
|
const hasRepo = Boolean(project?.giteaRepoUrl);
|
||||||
const hasPRD = Boolean(project?.prd);
|
const hasPRD = Boolean(project?.prd);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.2rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
fontWeight: 400, color: "#1a1a1a", marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
Deployment
|
Deployment
|
||||||
@@ -103,7 +102,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#3d5afe", fontWeight: 500 }}>{project.coolifyDeployUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
<a href={project.coolifyDeployUrl} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Open ↗
|
Open ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +116,7 @@ export default function DeploymentPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
<span style={{ display: "inline-flex", alignItems: "center", padding: "3px 9px", borderRadius: 4, fontSize: "0.68rem", fontWeight: 600, color: "#2e7d32", background: "#2e7d3210" }}>SSL Active</span>
|
||||||
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
<a href={`https://${project.customDomain}`} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Open ↗
|
Open ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +129,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
<div style={{ fontSize: "0.84rem", fontFamily: "IBM Plex Mono, monospace", color: "#6b6560", fontWeight: 500 }}>{project.giteaRepo}</div>
|
||||||
</div>
|
</div>
|
||||||
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
<a href={project.giteaRepoUrl} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "Outfit, sans-serif" }}>
|
style={{ padding: "5px 12px", borderRadius: 7, border: "1px solid #e0dcd4", background: "#fff", color: "#1a1a1a", fontSize: "0.7rem", fontWeight: 600, textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
View ↗
|
View ↗
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +139,7 @@ export default function DeploymentPage() {
|
|||||||
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
<div style={{ padding: "18px 0", textAlign: "center" }}>
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", marginBottom: 12 }}>
|
||||||
{!hasPRD
|
{!hasPRD
|
||||||
? "Complete your PRD with Atlas first, then build and deploy."
|
? "Complete your PRD with Vibn first, then build and deploy."
|
||||||
: !hasRepo
|
: !hasRepo
|
||||||
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
? "No repository yet — the Architect agent will scaffold one from your PRD."
|
||||||
: "No deployment yet — kick off a build to get a live URL."}
|
: "No deployment yet — kick off a build to get a live URL."}
|
||||||
@@ -166,7 +165,7 @@ export default function DeploymentPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleConnectDomain}
|
onClick={handleConnectDomain}
|
||||||
disabled={connecting}
|
disabled={connecting}
|
||||||
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
style={{ padding: "9px 18px", borderRadius: 7, border: "none", background: "#1a1a1a", color: "#fff", fontSize: "0.78rem", fontWeight: 600, cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", opacity: connecting ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{connecting ? "Connecting…" : "Connect"}
|
{connecting ? "Connecting…" : "Connect"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useState, useEffect } from "react";
|
import { use, useState, useEffect, Suspense } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
||||||
@@ -360,7 +361,7 @@ const LIBRARY_STYLE_OPTIONS: Record<string, LibraryStyleOptions> = {
|
|||||||
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>{label}</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{label}</span>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +383,7 @@ function OptionChip({
|
|||||||
display: "flex", alignItems: "center", gap: 5,
|
display: "flex", alignItems: "center", gap: 5,
|
||||||
padding: multi ? "4px 9px" : "4px 11px",
|
padding: multi ? "4px 9px" : "4px 11px",
|
||||||
borderRadius: 5, border: "1px solid",
|
borderRadius: 5, border: "1px solid",
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit", cursor: "pointer",
|
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
transition: "all 0.1s",
|
transition: "all 0.1s",
|
||||||
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
||||||
background: active ? "#1a1a1a" : "#fff",
|
background: active ? "#1a1a1a" : "#fff",
|
||||||
@@ -410,7 +411,7 @@ function ModeToggle({ value, onChange }: { value: string; onChange: (v: "dark" |
|
|||||||
key={m}
|
key={m}
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
style={{
|
style={{
|
||||||
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "Outfit",
|
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: "pointer", fontWeight: active ? 600 : 400,
|
cursor: "pointer", fontWeight: active ? 600 : 400,
|
||||||
background: active ? "#1a1a1a" : "transparent",
|
background: active ? "#1a1a1a" : "transparent",
|
||||||
color: active ? "#fff" : "#8a8478",
|
color: active ? "#fff" : "#8a8478",
|
||||||
@@ -621,7 +622,7 @@ function SurfaceSection({
|
|||||||
{ScaffoldComponent
|
{ScaffoldComponent
|
||||||
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
|
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
|
||||||
: (
|
: (
|
||||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "Outfit" }}>
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Select a library below to preview
|
Select a library below to preview
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -646,7 +647,7 @@ function SurfaceSection({
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
||||||
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
|
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit", cursor: "pointer", transition: "opacity 0.15s",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer", transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
||||||
@@ -659,7 +660,7 @@ function SurfaceSection({
|
|||||||
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
||||||
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
||||||
color: previewId && !saving ? "#fff" : "#b5b0a6",
|
color: previewId && !saving ? "#fff" : "#b5b0a6",
|
||||||
fontSize: "0.76rem", fontWeight: 600, fontFamily: "Outfit",
|
fontSize: "0.76rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: !previewId || saving ? "not-allowed" : "pointer",
|
cursor: !previewId || saving ? "not-allowed" : "pointer",
|
||||||
transition: "opacity 0.15s",
|
transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
@@ -669,7 +670,7 @@ function SurfaceSection({
|
|||||||
)}
|
)}
|
||||||
{activeTheme && (
|
{activeTheme && (
|
||||||
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
|
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
|
||||||
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "Outfit", flexShrink: 0 }}
|
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0 }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
||||||
>Docs ↗</a>
|
>Docs ↗</a>
|
||||||
@@ -678,7 +679,7 @@ function SurfaceSection({
|
|||||||
|
|
||||||
{/* 2. Library */}
|
{/* 2. Library */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Library</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Library</span>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||||
{surface.themes.map(theme => {
|
{surface.themes.map(theme => {
|
||||||
const isActive = theme.id === previewId;
|
const isActive = theme.id === previewId;
|
||||||
@@ -692,7 +693,7 @@ function SurfaceSection({
|
|||||||
style={{
|
style={{
|
||||||
display: "flex", alignItems: "center", gap: 4,
|
display: "flex", alignItems: "center", gap: 4,
|
||||||
padding: "4px 11px", borderRadius: 5, border: "1px solid",
|
padding: "4px 11px", borderRadius: 5, border: "1px solid",
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit", cursor: dimmed ? "not-allowed" : "pointer",
|
fontSize: "0.72rem", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: dimmed ? "not-allowed" : "pointer",
|
||||||
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
|
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
|
||||||
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
||||||
background: isActive ? "#1a1a1a" : "#fff",
|
background: isActive ? "#1a1a1a" : "#fff",
|
||||||
@@ -719,7 +720,7 @@ function SurfaceSection({
|
|||||||
{/* 4. Colour */}
|
{/* 4. Colour */}
|
||||||
{availableColorThemes.length > 0 && (
|
{availableColorThemes.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
|
||||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{availableColorThemes.map(ct => (
|
{availableColorThemes.map(ct => (
|
||||||
<button
|
<button
|
||||||
@@ -739,7 +740,7 @@ function SurfaceSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{activeColorTheme && (
|
{activeColorTheme && (
|
||||||
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "Outfit" }}>{activeColorTheme.label}</span>
|
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>{activeColorTheme.label}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -800,7 +801,7 @@ function SurfaceSection({
|
|||||||
{/* Colour swatches when locked (read-only) */}
|
{/* Colour swatches when locked (read-only) */}
|
||||||
{isLocked && availableColorThemes.length > 0 && (
|
{isLocked && availableColorThemes.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||||
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>Colour</span>
|
||||||
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
||||||
{availableColorThemes.map(ct => (
|
{availableColorThemes.map(ct => (
|
||||||
<button key={ct.id} title={ct.label} disabled
|
<button key={ct.id} title={ct.label} disabled
|
||||||
@@ -862,8 +863,8 @@ function SurfacePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ padding: "28px 32px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Design surfaces
|
Design surfaces
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
||||||
@@ -897,7 +898,7 @@ function SurfacePicker({
|
|||||||
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
||||||
background: isSelected ? "#1a1a1a08" : "#fff",
|
background: isSelected ? "#1a1a1a08" : "#fff",
|
||||||
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
|
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
|
||||||
cursor: "pointer", transition: "all 0.12s", fontFamily: "Outfit",
|
cursor: "pointer", transition: "all 0.12s", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
||||||
@@ -936,7 +937,7 @@ function SurfacePicker({
|
|||||||
padding: "9px 20px", borderRadius: 7, border: "none",
|
padding: "9px 20px", borderRadius: 7, border: "none",
|
||||||
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
||||||
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
|
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
|
||||||
fontSize: "0.82rem", fontWeight: 600, fontFamily: "Outfit",
|
fontSize: "0.82rem", fontWeight: 600, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
|
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
|
||||||
transition: "opacity 0.15s",
|
transition: "opacity 0.15s",
|
||||||
}}
|
}}
|
||||||
@@ -946,7 +947,7 @@ function SurfacePicker({
|
|||||||
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
||||||
</button>
|
</button>
|
||||||
{selected.size === 0 && (
|
{selected.size === 0 && (
|
||||||
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit" }}>
|
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
Select at least one surface to continue
|
Select at least one surface to continue
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -958,8 +959,9 @@ function SurfacePicker({
|
|||||||
// Page
|
// Page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
function DesignPageInner({ projectId }: { projectId: string }) {
|
||||||
const { projectId } = use(params);
|
const searchParams = useSearchParams();
|
||||||
|
const requestedSurface = searchParams.get("surface");
|
||||||
|
|
||||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
const [surfaces, setSurfaces] = useState<string[]>([]);
|
||||||
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
||||||
@@ -979,7 +981,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
setSurfaces(loaded);
|
setSurfaces(loaded);
|
||||||
setSurfaceThemes(d.surfaceThemes ?? {});
|
setSurfaceThemes(d.surfaceThemes ?? {});
|
||||||
setSelectedThemes(d.surfaceThemes ?? {});
|
setSelectedThemes(d.surfaceThemes ?? {});
|
||||||
if (loaded.length > 0) setActiveSurfaceId(loaded[0]);
|
// Honour ?surface= param if valid, otherwise default to first
|
||||||
|
const initial = requestedSurface && loaded.includes(requestedSurface)
|
||||||
|
? requestedSurface
|
||||||
|
: loaded[0] ?? null;
|
||||||
|
setActiveSurfaceId(initial);
|
||||||
return loaded;
|
return loaded;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1050,7 +1056,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
|
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
</div>
|
</div>
|
||||||
@@ -1066,7 +1072,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
const lockedCount = Object.keys(surfaceThemes).length;
|
const lockedCount = Object.keys(surfaceThemes).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
|
||||||
{/* Left nav */}
|
{/* Left nav */}
|
||||||
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
||||||
@@ -1089,7 +1095,7 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
color: isActive ? "#1a1a1a" : "#6b6560",
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
||||||
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
||||||
cursor: "pointer", transition: "all 0.12s", position: "relative",
|
cursor: "pointer", transition: "all 0.12s", position: "relative",
|
||||||
fontFamily: "Outfit",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
|
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
|
||||||
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
||||||
@@ -1107,11 +1113,11 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
|
|
||||||
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
||||||
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
||||||
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "Outfit", marginBottom: 6 }}>✓ All locked</p>
|
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", marginBottom: 6 }}>✓ All locked</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSurfaces([])}
|
onClick={() => setSurfaces([])}
|
||||||
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "Outfit", padding: 0 }}
|
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", padding: 0 }}
|
||||||
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
||||||
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
||||||
>
|
>
|
||||||
@@ -1137,3 +1143,12 @@ export default function DesignPage({ params }: { params: Promise<{ workspace: st
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
||||||
|
const { projectId } = use(params);
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<DesignPageInner projectId={projectId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ export default function GrowPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Grow
|
Grow
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||||
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
144
app/[workspace]/project/[projectId]/(workspace)/growth/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{
|
||||||
|
id: "marketing-site",
|
||||||
|
label: "Marketing Site",
|
||||||
|
icon: "◌",
|
||||||
|
title: "Marketing Site",
|
||||||
|
desc: "Your public-facing website — hero, features, pricing, blog, and landing pages. Connected to your design surface and deployed via your infrastructure.",
|
||||||
|
items: ["Hero & Landing", "Features", "Pricing Page", "Blog", "Case Studies", "About"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "communications",
|
||||||
|
label: "Communications",
|
||||||
|
icon: "◈",
|
||||||
|
title: "Communications",
|
||||||
|
desc: "Outbound messaging — product announcements, newsletters, launch emails, and drip campaigns sent to your audience.",
|
||||||
|
items: ["Announcements", "Newsletter", "Launch Sequence", "Drip Campaigns"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "channels",
|
||||||
|
label: "Channels",
|
||||||
|
icon: "↗",
|
||||||
|
title: "Distribution Channels",
|
||||||
|
desc: "Where your product gets discovered — SEO, social, Product Hunt, app stores, partnerships, and paid acquisition.",
|
||||||
|
items: ["SEO & Search", "Social Media", "Product Hunt", "App Stores", "Partnerships", "Paid Ads"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pages",
|
||||||
|
label: "Pages",
|
||||||
|
icon: "▭",
|
||||||
|
title: "Pages",
|
||||||
|
desc: "Individual landing pages for campaigns, experiments, and specific audience segments. Build, publish, and A/B test.",
|
||||||
|
items: ["Campaign Pages", "A/B Tests", "Event Pages", "Partner Pages", "Waitlist"],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SectionId = typeof SECTIONS[number]["id"];
|
||||||
|
|
||||||
|
const NAV_GROUP: React.CSSProperties = {
|
||||||
|
fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6",
|
||||||
|
letterSpacing: "0.09em", textTransform: "uppercase",
|
||||||
|
padding: "14px 12px 6px", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
};
|
||||||
|
|
||||||
|
function GrowthInner() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
|
||||||
|
const activeId = (searchParams.get("section") ?? "marketing-site") as SectionId;
|
||||||
|
const active = SECTIONS.find(s => s.id === activeId) ?? SECTIONS[0];
|
||||||
|
|
||||||
|
const setSection = (id: string) =>
|
||||||
|
router.push(`/${workspace}/project/${projectId}/growth?section=${id}`, { scroll: false });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", overflow: "hidden" }}>
|
||||||
|
|
||||||
|
{/* Left nav */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0, borderRight: "1px solid #e8e4dc", background: "#faf8f5", display: "flex", flexDirection: "column", overflow: "auto" }}>
|
||||||
|
<div style={NAV_GROUP}>Growth</div>
|
||||||
|
{SECTIONS.map(s => {
|
||||||
|
const isActive = activeId === s.id;
|
||||||
|
return (
|
||||||
|
<button key={s.id} onClick={() => setSection(s.id)} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left",
|
||||||
|
background: isActive ? "#f0ece4" : "transparent", border: "none", cursor: "pointer",
|
||||||
|
padding: "6px 12px", borderRadius: 5,
|
||||||
|
fontSize: "0.78rem", fontWeight: isActive ? 600 : 440,
|
||||||
|
color: isActive ? "#1a1a1a" : "#5a5550",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "#f6f4f0"; }}
|
||||||
|
onMouseLeave={e => { if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent"; }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.65rem", opacity: 0.55, width: 14, textAlign: "center" }}>{s.icon}</span>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: "auto", display: "flex", flexDirection: "column" }}>
|
||||||
|
<div style={{ padding: "28px 32px", maxWidth: 800 }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "1.1rem", fontWeight: 700, color: "#1a1a1a", marginBottom: 6 }}>{active.title}</div>
|
||||||
|
<div style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.65, maxWidth: 520 }}>{active.desc}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature items */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12, marginBottom: 32 }}>
|
||||||
|
{active.items.map(item => (
|
||||||
|
<div key={item} style={{
|
||||||
|
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9,
|
||||||
|
padding: "14px 16px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "0.8rem", fontWeight: 500, color: "#1a1a1a" }}>{item}</span>
|
||||||
|
<span style={{ fontSize: "0.65rem", color: "#c5c0b8", background: "#f6f4f0", padding: "2px 7px", borderRadius: 4 }}>Soon</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(135deg, #1a1a1a 0%, #2d2820 100%)",
|
||||||
|
borderRadius: 12, padding: "24px 28px",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 20,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "#fff", marginBottom: 4 }}>
|
||||||
|
{active.title} is coming to VIBN
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75rem", color: "#8a8478", lineHeight: 1.5 }}>
|
||||||
|
We're building this section next. Shape it by telling us what you need.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button style={{
|
||||||
|
background: "#d4a04a", color: "#fff", border: "none", borderRadius: 8,
|
||||||
|
padding: "9px 20px", fontSize: "0.78rem", fontWeight: 600,
|
||||||
|
cursor: "pointer", whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
Give feedback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GrowthPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
||||||
|
<GrowthInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||||
|
|
||||||
|
export default function InfrastructurePage() {
|
||||||
|
return (
|
||||||
|
<ProjectInfraPanel routeBase="infrastructure" navGroupLabel="Infrastructure" />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ export default function InsightsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 560 }}>
|
<div style={{ maxWidth: 560 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Insights
|
Insights
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 28 }}>
|
||||||
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
81
app/[workspace]/project/[projectId]/(workspace)/layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
|
import { ProjectShell } from "@/components/layout/project-shell";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const plusJakarta = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-justine-jakarta",
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ProjectData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status?: string;
|
||||||
|
progress?: number;
|
||||||
|
discoveryPhase?: number;
|
||||||
|
capturedData?: Record<string, string>;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
featureCount?: number;
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectData(projectId: string): Promise<ProjectData> {
|
||||||
|
try {
|
||||||
|
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
||||||
|
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const { data, created_at, updated_at } = rows[0];
|
||||||
|
return {
|
||||||
|
name: data?.productName || data?.name || "Project",
|
||||||
|
description: data?.productVision || data?.description,
|
||||||
|
status: data?.status,
|
||||||
|
progress: data?.progress ?? 0,
|
||||||
|
discoveryPhase: data?.discoveryPhase ?? 0,
|
||||||
|
capturedData: data?.capturedData ?? {},
|
||||||
|
createdAt: created_at,
|
||||||
|
updatedAt: updated_at,
|
||||||
|
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
||||||
|
creationMode: data?.creationMode ?? "fresh",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching project:", error);
|
||||||
|
}
|
||||||
|
return { name: "Project" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProjectLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspace, projectId } = await params;
|
||||||
|
const project = await getProjectData(projectId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={plusJakarta.variable} style={{ height: "100%", minHeight: "100dvh" }}>
|
||||||
|
<ProjectShell
|
||||||
|
workspace={workspace}
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={project.name}
|
||||||
|
projectDescription={project.description}
|
||||||
|
projectStatus={project.status}
|
||||||
|
projectProgress={project.progress}
|
||||||
|
discoveryPhase={project.discoveryPhase}
|
||||||
|
capturedData={project.capturedData}
|
||||||
|
createdAt={project.createdAt}
|
||||||
|
updatedAt={project.updatedAt}
|
||||||
|
featureCount={project.featureCount}
|
||||||
|
creationMode={project.creationMode}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ProjectShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||||
|
|
||||||
|
export default function MvpSetupArchitectPage() {
|
||||||
|
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||||
|
const base = `/${workspace}/project/${projectId}`;
|
||||||
|
return (
|
||||||
|
<MvpSetupStepPlaceholder
|
||||||
|
title="Architect"
|
||||||
|
subtitle="Lock in discovery — stack choices, surfaces, and what we’re shipping."
|
||||||
|
body="Use Task to run discovery phases and save answers. When you’re ready, continue to Design."
|
||||||
|
primaryHref={`${base}/tasks`}
|
||||||
|
primaryLabel="Open Task"
|
||||||
|
nextHref={`${base}/mvp-setup/design`}
|
||||||
|
nextLabel="Continue to Design"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { MvpSetupDescribeView } from "@/components/project-main/MvpSetupDescribeView";
|
||||||
|
|
||||||
|
export default function MvpSetupDescribePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
return <MvpSetupDescribeView projectId={projectId} workspace={workspace} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||||
|
|
||||||
|
export default function MvpSetupDesignPage() {
|
||||||
|
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||||
|
const base = `/${workspace}/project/${projectId}`;
|
||||||
|
return (
|
||||||
|
<MvpSetupStepPlaceholder
|
||||||
|
title="Design"
|
||||||
|
subtitle="Pick feel, color, and layout — we’ll apply it across your product surfaces."
|
||||||
|
body="The full design studio lives on the Design tab. When it looks right, move on to how you’ll grow."
|
||||||
|
primaryHref={`${base}/design`}
|
||||||
|
primaryLabel="Open Design"
|
||||||
|
nextHref={`${base}/mvp-setup/website`}
|
||||||
|
nextLabel="Continue to Website"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MvpSetupLayoutClient } from "@/components/project-main/MvpSetupLayoutClient";
|
||||||
|
|
||||||
|
export default async function MvpSetupWizardLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspace, projectId } = await params;
|
||||||
|
return (
|
||||||
|
<MvpSetupLayoutClient workspace={workspace} projectId={projectId}>
|
||||||
|
{children}
|
||||||
|
</MvpSetupLayoutClient>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { MvpSetupStepPlaceholder } from "@/components/project-main/MvpSetupStepPlaceholder";
|
||||||
|
|
||||||
|
export default function MvpSetupWebsitePage() {
|
||||||
|
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||||
|
const base = `/${workspace}/project/${projectId}`;
|
||||||
|
return (
|
||||||
|
<MvpSetupStepPlaceholder
|
||||||
|
title="Website"
|
||||||
|
subtitle="Voice, topics, and marketing style — what people see before they sign up."
|
||||||
|
body="Tune growth messaging on the Grow tab. Then review everything and kick off your MVP build."
|
||||||
|
primaryHref={`${base}/growth`}
|
||||||
|
primaryLabel="Open Grow"
|
||||||
|
nextHref={`${base}/mvp-setup/launch`}
|
||||||
|
nextLabel="Review & launch"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { BuildMvpJustineV2 } from "@/components/project-main/BuildMvpJustineV2";
|
||||||
|
import { JM } from "@/components/project-creation/modal-theme";
|
||||||
|
|
||||||
|
interface SurfaceEntry {
|
||||||
|
id: string;
|
||||||
|
lockedTheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MvpSetupLaunchPage() {
|
||||||
|
const { workspace, projectId } = useParams() as { workspace: string; projectId: string };
|
||||||
|
const router = useRouter();
|
||||||
|
const [productName, setProductName] = useState("Your product");
|
||||||
|
const [giteaRepo, setGiteaRepo] = useState<string | undefined>();
|
||||||
|
const [surfaces, setSurfaces] = useState<SurfaceEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const p = d.project;
|
||||||
|
if (p) {
|
||||||
|
setProductName(p.productName || p.name || "Your product");
|
||||||
|
setGiteaRepo(p.giteaRepo);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
fetch(`/api/projects/${projectId}/design-surfaces`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const ids: string[] = d.surfaces ?? [];
|
||||||
|
const themes: Record<string, string> = d.surfaceThemes ?? {};
|
||||||
|
setSurfaces(ids.map(id => ({ id, lockedTheme: themes[id] })));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const webappSurface = surfaces.find(s => s.id === "webapp");
|
||||||
|
const marketingSurface = surfaces.find(s => s.id === "marketing");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "hidden", background: JM.inputBg }}>
|
||||||
|
<BuildMvpJustineV2
|
||||||
|
workspace={workspace}
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={productName}
|
||||||
|
giteaRepo={giteaRepo}
|
||||||
|
accentLabel={webappSurface?.lockedTheme}
|
||||||
|
websiteStyle={marketingSurface?.lockedTheme}
|
||||||
|
onSwitchToPreview={() => {
|
||||||
|
router.push(`/${workspace}/project/${projectId}/build?section=preview`, { scroll: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/** Root: no sidebar — launch step uses full Justine chrome; wizard steps use (wizard)/layout. */
|
||||||
|
export default function MvpSetupRootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function MvpSetupIndexPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspace, projectId } = await params;
|
||||||
|
redirect(`/${workspace}/project/${projectId}/mvp-setup/describe`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { isClientDevProjectBypass } from "@/lib/dev-bypass";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { JM } from "@/components/project-creation/modal-theme";
|
||||||
|
import { FreshIdeaMain } from "@/components/project-main/FreshIdeaMain";
|
||||||
|
import { ChatImportMain } from "@/components/project-main/ChatImportMain";
|
||||||
|
import { CodeImportMain } from "@/components/project-main/CodeImportMain";
|
||||||
|
import { MigrateMain } from "@/components/project-main/MigrateMain";
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
name?: string;
|
||||||
|
stage?: "discovery" | "architecture" | "building" | "active";
|
||||||
|
creationMode?: "fresh" | "chat-import" | "code-import" | "migration";
|
||||||
|
creationStage?: string;
|
||||||
|
sourceData?: {
|
||||||
|
chatText?: string;
|
||||||
|
repoUrl?: string;
|
||||||
|
liveUrl?: string;
|
||||||
|
hosting?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
migrationPlan?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectOverviewPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const { status: authStatus } = useSession();
|
||||||
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const bypass = isClientDevProjectBypass();
|
||||||
|
if (!bypass && authStatus !== "authenticated") {
|
||||||
|
if (authStatus === "unauthenticated") setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bypass && authStatus === "loading") return;
|
||||||
|
fetch(`/api/projects/${projectId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setProject(d.project))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [authStatus, projectId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
height: "100%", fontFamily: JM.fontSans,
|
||||||
|
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||||
|
}}>
|
||||||
|
<Loader2 style={{ width: 24, height: 24, color: JM.indigo }} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
height: "100%", fontFamily: JM.fontSans, color: JM.muted, fontSize: 14,
|
||||||
|
background: "linear-gradient(180deg, #FAFAFA 0%, #F5F3FF 100%)",
|
||||||
|
}}>
|
||||||
|
Project not found.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectName = project.productName || project.name || "Untitled";
|
||||||
|
const mode = project.creationMode ?? "fresh";
|
||||||
|
|
||||||
|
if (mode === "chat-import") {
|
||||||
|
return (
|
||||||
|
<ChatImportMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult as Parameters<typeof ChatImportMain>[0]["analysisResult"]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "code-import") {
|
||||||
|
return (
|
||||||
|
<CodeImportMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "migration") {
|
||||||
|
return (
|
||||||
|
<MigrateMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
sourceData={project.sourceData}
|
||||||
|
analysisResult={project.analysisResult}
|
||||||
|
migrationPlan={project.migrationPlan}
|
||||||
|
creationStage={project.creationStage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: "fresh" — wraps AtlasChat with decision banner
|
||||||
|
return (
|
||||||
|
<FreshIdeaMain
|
||||||
|
projectId={projectId}
|
||||||
|
projectName={projectName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
11
app/[workspace]/project/[projectId]/(workspace)/prd/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
/** Legacy URL — project work now lives under Tasks (PRD is the first task). */
|
||||||
|
export default async function PrdRedirectPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ workspace: string; projectId: string }>;
|
||||||
|
}) {
|
||||||
|
const { workspace, projectId } = await params;
|
||||||
|
redirect(`/${workspace}/project/${projectId}/tasks`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { ProjectInfraPanel } from "@/components/project-main/ProjectInfraPanel";
|
||||||
|
|
||||||
|
export default function RunPage() {
|
||||||
|
return <ProjectInfraPanel routeBase="run" navGroupLabel="Run" />;
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ export default function ProjectSettingsPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -131,10 +131,10 @@ export default function ProjectSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "28px 32px", overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 480 }}>
|
<div style={{ maxWidth: 480 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
Project Settings
|
Project Settings
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: 24 }}>
|
||||||
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
507
app/[workspace]/project/[projectId]/(workspace)/tasks/page.tsx
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type CSSProperties } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
// Maps each PRD section to the discovery phase that populates it
|
||||||
|
const PRD_SECTIONS = [
|
||||||
|
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
||||||
|
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
||||||
|
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
||||||
|
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
||||||
|
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
||||||
|
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
||||||
|
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
||||||
|
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
||||||
|
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
||||||
|
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: "features_scope" },
|
||||||
|
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
||||||
|
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SavedPhase {
|
||||||
|
phase: string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
saved_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v: unknown): string {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
||||||
|
border: "1px solid #e8e4dc", overflow: "hidden",
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(e => !e)}
|
||||||
|
style={{
|
||||||
|
width: "100%", textAlign: "left", padding: "10px 14px",
|
||||||
|
background: "none", border: "none", cursor: "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
||||||
|
{phase.summary}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
||||||
|
{expanded ? "▲" : "▼"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && entries.length > 0 && (
|
||||||
|
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} style={{ marginTop: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
||||||
|
}}>
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
||||||
|
{formatValue(v)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchApp { name: string; type: string; description: string; tech?: string[]; screens?: string[] }
|
||||||
|
interface ArchInfra { name: string; reason: string }
|
||||||
|
interface ArchPackage { name: string; description: string }
|
||||||
|
interface ArchIntegration { name: string; required?: boolean; notes?: string }
|
||||||
|
interface Architecture {
|
||||||
|
productName?: string;
|
||||||
|
productType?: string;
|
||||||
|
summary?: string;
|
||||||
|
apps?: ArchApp[];
|
||||||
|
packages?: ArchPackage[];
|
||||||
|
infrastructure?: ArchInfra[];
|
||||||
|
integrations?: ArchIntegration[];
|
||||||
|
designSurfaces?: string[];
|
||||||
|
riskNotes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArchitectureView({ arch }: { arch: Architecture }) {
|
||||||
|
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ fontSize: "0.6rem", fontWeight: 700, color: "#b5b0a6", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: 10 }}>{title}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const Card = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div style={{ background: "#fff", border: "1px solid #e8e4dc", borderRadius: 9, padding: "14px 16px", marginBottom: 8 }}>{children}</div>
|
||||||
|
);
|
||||||
|
const Tag = ({ label }: { label: string }) => (
|
||||||
|
<span style={{ background: "#f0ece4", borderRadius: 4, padding: "2px 7px", fontSize: "0.68rem", color: "#6b6560", fontFamily: "IBM Plex Mono, monospace", marginRight: 4, display: "inline-block", marginBottom: 3 }}>{label}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
{arch.summary && (
|
||||||
|
<div style={{ background: "#1a1a1a", borderRadius: 10, padding: "18px 22px", marginBottom: 24, color: "#e8e4dc", fontSize: "0.88rem", lineHeight: 1.7 }}>
|
||||||
|
{arch.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(arch.apps ?? []).length > 0 && (
|
||||||
|
<Section title="Applications">
|
||||||
|
{arch.apps!.map(a => (
|
||||||
|
<Card key={a.name}>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>{a.name}</span>
|
||||||
|
<span style={{ fontSize: "0.72rem", color: "#9a9490" }}>{a.type}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.55, marginBottom: a.tech?.length ? 8 : 0 }}>{a.description}</div>
|
||||||
|
{a.tech?.map(t => <Tag key={t} label={t} />)}
|
||||||
|
{a.screens && a.screens.length > 0 && (
|
||||||
|
<div style={{ marginTop: 6, fontSize: "0.72rem", color: "#a09a90" }}>Screens: {a.screens.join(", ")}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.packages ?? []).length > 0 && (
|
||||||
|
<Section title="Shared Packages">
|
||||||
|
{arch.packages!.map(p => (
|
||||||
|
<Card key={p.name}>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||||
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>{p.name}</span>
|
||||||
|
<span style={{ fontSize: "0.78rem", color: "#4a4640" }}>{p.description}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.infrastructure ?? []).length > 0 && (
|
||||||
|
<Section title="Infrastructure">
|
||||||
|
{arch.infrastructure!.map(i => (
|
||||||
|
<Card key={i.name}>
|
||||||
|
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{i.name}</div>
|
||||||
|
<div style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>{i.reason}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.integrations ?? []).length > 0 && (
|
||||||
|
<Section title="Integrations">
|
||||||
|
{arch.integrations!.map(i => (
|
||||||
|
<Card key={i.name}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: i.notes ? 4 : 0 }}>
|
||||||
|
<span style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a" }}>{i.name}</span>
|
||||||
|
{i.required && <span style={{ fontSize: "0.62rem", background: "#fef3c7", color: "#92400e", padding: "1px 6px", borderRadius: 4 }}>required</span>}
|
||||||
|
</div>
|
||||||
|
{i.notes && <div style={{ fontSize: "0.78rem", color: "#4a4640" }}>{i.notes}</div>}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
{(arch.riskNotes ?? []).length > 0 && (
|
||||||
|
<Section title="Architectural Risks">
|
||||||
|
{arch.riskNotes!.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", gap: 10, alignItems: "flex-start", marginBottom: 8 }}>
|
||||||
|
<span style={{ fontSize: "0.72rem", color: "#d97706", marginTop: 2, flexShrink: 0 }}>⚠</span>
|
||||||
|
<span style={{ fontSize: "0.82rem", color: "#4a4640", lineHeight: 1.5 }}>{r}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const projectId = params.projectId as string;
|
||||||
|
const workspace = params.workspace as string;
|
||||||
|
const [prd, setPrd] = useState<string | null>(null);
|
||||||
|
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
||||||
|
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState<"prd" | "architecture">("prd");
|
||||||
|
const [archGenerating, setArchGenerating] = useState(false);
|
||||||
|
const [archError, setArchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
||||||
|
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
||||||
|
]).then(([projectData, phaseData]) => {
|
||||||
|
setPrd(projectData?.project?.prd ?? null);
|
||||||
|
setArchitecture(projectData?.project?.architecture ?? null);
|
||||||
|
setSavedPhases(phaseData?.phases ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleGenerateArchitecture = async () => {
|
||||||
|
setArchGenerating(true);
|
||||||
|
setArchError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/architecture`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Generation failed");
|
||||||
|
setArchitecture(data.architecture);
|
||||||
|
setActiveTab("architecture");
|
||||||
|
} catch (e) {
|
||||||
|
setArchError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
} finally {
|
||||||
|
setArchGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
||||||
|
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
||||||
|
|
||||||
|
const sections = PRD_SECTIONS.map(s => ({
|
||||||
|
...s,
|
||||||
|
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
||||||
|
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doneCount = sections.filter(s => s.isDone).length;
|
||||||
|
const totalPct = Math.round((doneCount / sections.length) * 100);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", color: "#a09a90" }}>
|
||||||
|
Loading tasks…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqStatus = prd
|
||||||
|
? "Complete"
|
||||||
|
: doneCount > 0
|
||||||
|
? `In progress · ${doneCount}/${sections.length} sections`
|
||||||
|
: "Not started";
|
||||||
|
const archStatus = architecture
|
||||||
|
? "Complete"
|
||||||
|
: prd
|
||||||
|
? "Ready to generate"
|
||||||
|
: "Blocked — finish requirements first";
|
||||||
|
|
||||||
|
const taskCardBase: CSSProperties = {
|
||||||
|
flex: "1 1 240px",
|
||||||
|
maxWidth: 320,
|
||||||
|
textAlign: "left" as const,
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderRadius: 10,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
transition: "border-color 0.12s, box-shadow 0.12s",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}>
|
||||||
|
|
||||||
|
<header style={{ marginBottom: 24, maxWidth: 720 }}>
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||||
|
fontSize: "1.35rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}>
|
||||||
|
Tasks
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: "0.82rem", color: "#6b6560", lineHeight: 1.55, margin: 0 }}>
|
||||||
|
Work is tracked as tasks—similar in spirit to agent task boards like{" "}
|
||||||
|
<a href="https://github.com/777genius/claude_agent_teams_ui" target="_blank" rel="noopener noreferrer" style={{ color: "#4a4640" }}>
|
||||||
|
Claude Agent Teams UI
|
||||||
|
</a>
|
||||||
|
. Your <strong>product requirements (PRD)</strong> is the first task; technical architecture is the next once requirements are captured.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Task selector — PRD is a task; architecture is a follow-on task */}
|
||||||
|
<div style={{ display: "flex", gap: 12, marginBottom: 28, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("prd")}
|
||||||
|
style={{
|
||||||
|
...taskCardBase,
|
||||||
|
border: activeTab === "prd" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||||
|
background: activeTab === "prd" ? "#faf8f5" : "#fff",
|
||||||
|
boxShadow: activeTab === "prd" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
|
Product requirements
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||||
|
PRD · {reqStatus}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => architecture && setActiveTab("architecture")}
|
||||||
|
disabled={!architecture}
|
||||||
|
style={{
|
||||||
|
...taskCardBase,
|
||||||
|
border: activeTab === "architecture" ? "2px solid #1a1a1a" : "1px solid #e8e4dc",
|
||||||
|
background: activeTab === "architecture" ? "#faf8f5" : "#fff",
|
||||||
|
boxShadow: activeTab === "architecture" ? "0 2px 8px #1a1a1a0a" : "0 1px 2px #1a1a1a05",
|
||||||
|
opacity: architecture ? 1 : 0.72,
|
||||||
|
cursor: architecture ? "pointer" : "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 4 }}>
|
||||||
|
Technical architecture
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.72rem", color: "#888780", lineHeight: 1.4 }}>
|
||||||
|
{archStatus}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next step banner — PRD done but no architecture yet */}
|
||||||
|
{prd && !architecture && activeTab === "prd" && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 24, padding: "18px 22px",
|
||||||
|
background: "#1a1a1a", borderRadius: 10,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
gap: 16, flexWrap: "wrap",
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: "0.88rem", fontWeight: 700, color: "#fff", marginBottom: 4 }}>
|
||||||
|
Next: Generate technical architecture
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.76rem", color: "#a09a90", lineHeight: 1.5 }}>
|
||||||
|
The AI will read your PRD and recommend the apps, services, and infrastructure your product needs. Takes ~30 seconds.
|
||||||
|
</div>
|
||||||
|
{archError && (
|
||||||
|
<div style={{ fontSize: "0.74rem", color: "#f87171", marginTop: 6 }}>⚠ {archError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateArchitecture}
|
||||||
|
disabled={archGenerating}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px", borderRadius: 8, border: "none",
|
||||||
|
background: archGenerating ? "#4a4640" : "#fff",
|
||||||
|
color: archGenerating ? "#a09a90" : "#1a1a1a",
|
||||||
|
fontSize: "0.82rem", fontWeight: 700,
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
cursor: archGenerating ? "default" : "pointer",
|
||||||
|
flexShrink: 0, display: "flex", alignItems: "center", gap: 8,
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{archGenerating && (
|
||||||
|
<span style={{
|
||||||
|
width: 12, height: 12, borderRadius: "50%",
|
||||||
|
border: "2px solid #60606040", borderTopColor: "#a09a90",
|
||||||
|
animation: "spin 0.7s linear infinite", display: "inline-block",
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{archGenerating ? "Analysing PRD…" : "Generate architecture →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Architecture tab */}
|
||||||
|
{activeTab === "architecture" && architecture && (
|
||||||
|
<ArchitectureView arch={architecture} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRD tab — finalized */}
|
||||||
|
{activeTab === "prd" && prd && (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
||||||
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
||||||
|
Product Requirements
|
||||||
|
</h3>
|
||||||
|
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
||||||
|
PRD complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
||||||
|
padding: "28px 32px", lineHeight: 1.8,
|
||||||
|
fontSize: "0.88rem", color: "#2a2824",
|
||||||
|
whiteSpace: "pre-wrap", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
|
}}>
|
||||||
|
{prd}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRD tab — section progress (no finalized PRD yet) */}
|
||||||
|
{activeTab === "prd" && !prd && (
|
||||||
|
/* ── Section progress view ── */
|
||||||
|
<div style={{ maxWidth: 680 }}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 16,
|
||||||
|
padding: "16px 20px", background: "#fff",
|
||||||
|
border: "1px solid #e8e4dc", borderRadius: 10,
|
||||||
|
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
||||||
|
}}>
|
||||||
|
{totalPct}%
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
||||||
|
<div style={{
|
||||||
|
height: "100%", borderRadius: 2,
|
||||||
|
width: `${totalPct}%`, background: "#1a1a1a",
|
||||||
|
transition: "width 0.6s ease",
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
||||||
|
{doneCount}/{sections.length} sections
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sections */}
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
style={{
|
||||||
|
padding: "14px 18px", marginBottom: 6,
|
||||||
|
background: "#fff", borderRadius: 10,
|
||||||
|
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
||||||
|
animationDelay: `${i * 0.04}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
|
{/* Status icon */}
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
||||||
|
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
fontSize: "0.65rem", fontWeight: 700,
|
||||||
|
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
||||||
|
}}>
|
||||||
|
{s.isDone ? "✓" : "○"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
flex: 1, fontSize: "0.84rem",
|
||||||
|
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
||||||
|
fontWeight: s.isDone ? 500 : 400,
|
||||||
|
}}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{s.isDone && s.savedPhase && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#2e7d32", background: "#2e7d3210",
|
||||||
|
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
saved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!s.isDone && !s.phaseId && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
color: "#b5b0a6", padding: "2px 7px",
|
||||||
|
}}>
|
||||||
|
generated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expandable phase data */}
|
||||||
|
{s.isDone && s.savedPhase && (
|
||||||
|
<PhaseDataCard phase={s.savedPhase} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending hint */}
|
||||||
|
{!s.isDone && (
|
||||||
|
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
||||||
|
{s.phaseId
|
||||||
|
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Vibn`
|
||||||
|
: "Will be generated when PRD is finalized"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{doneCount === 0 && (
|
||||||
|
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
||||||
|
Continue chatting with Vibn — saved phases will appear here automatically.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface App {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
description: string;
|
|
||||||
tech: string[];
|
|
||||||
screens: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Package {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Infra {
|
|
||||||
name: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Integration {
|
|
||||||
name: string;
|
|
||||||
required: boolean;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Architecture {
|
|
||||||
productName: string;
|
|
||||||
productType: string;
|
|
||||||
summary: string;
|
|
||||||
apps: App[];
|
|
||||||
packages: Package[];
|
|
||||||
infrastructure: Infra[];
|
|
||||||
integrations: Integration[];
|
|
||||||
designSurfaces: string[];
|
|
||||||
riskNotes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", fontWeight: 700, color: "#a09a90",
|
|
||||||
letterSpacing: "0.12em", textTransform: "uppercase",
|
|
||||||
marginBottom: 10, marginTop: 28,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppCard({ app }: { app: App }) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
web: "🌐", api: "⚡", simulator: "🎮", admin: "🔧",
|
|
||||||
mobile: "📱", worker: "⚙️", engine: "🎯",
|
|
||||||
};
|
|
||||||
const icon = Object.entries(icons).find(([k]) => app.name.toLowerCase().includes(k))?.[1] ?? "📦";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
marginBottom: 8, overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(o => !o)}
|
|
||||||
style={{
|
|
||||||
width: "100%", textAlign: "left", background: "none", border: "none",
|
|
||||||
cursor: "pointer", padding: "14px 18px",
|
|
||||||
display: "flex", alignItems: "center", gap: 12,
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1.2rem" }}>{icon}</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
||||||
<span style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a" }}>
|
|
||||||
apps/{app.name}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#6b6560", background: "#f0ece4",
|
|
||||||
padding: "2px 7px", borderRadius: 4,
|
|
||||||
}}>
|
|
||||||
{app.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.78rem", color: "#8a8478", marginTop: 2 }}>
|
|
||||||
{app.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: "0.7rem", color: "#c5c0b8" }}>{open ? "▲" : "▼"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div style={{ padding: "0 18px 16px", borderTop: "1px solid #f0ece4" }}>
|
|
||||||
{app.tech.length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Stack</div>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 5 }}>
|
|
||||||
{app.tech.map((t, i) => (
|
|
||||||
<span key={i} style={{
|
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#4a4640", background: "#f6f4f0",
|
|
||||||
border: "1px solid #e8e4dc", padding: "2px 8px", borderRadius: 4,
|
|
||||||
}}>{t}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{app.screens.length > 0 && (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<div style={{ fontSize: "0.62rem", color: "#b5b0a6", textTransform: "uppercase", letterSpacing: "0.06em", marginBottom: 6 }}>Key screens</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
|
||||||
{app.screens.map((s, i) => (
|
|
||||||
<div key={i} style={{ fontSize: "0.78rem", color: "#4a4640", display: "flex", alignItems: "center", gap: 6 }}>
|
|
||||||
<span style={{ color: "#c5c0b8" }}>→</span> {s}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BuildPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const workspace = params.workspace as string;
|
|
||||||
|
|
||||||
const [prd, setPrd] = useState<string | null>(null);
|
|
||||||
const [architecture, setArchitecture] = useState<Architecture | null>(null);
|
|
||||||
const [architectureConfirmed, setArchitectureConfirmed] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch(`/api/projects/${projectId}/architecture`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => {
|
|
||||||
setPrd(d.prd);
|
|
||||||
setArchitecture(d.architecture ?? null);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(() => setLoading(false));
|
|
||||||
|
|
||||||
// Also check confirmed flag
|
|
||||||
fetch(`/api/projects/${projectId}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(d => setArchitectureConfirmed(d.project?.architectureConfirmed === true))
|
|
||||||
.catch(() => {});
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const handleGenerate = async (force = false) => {
|
|
||||||
setGenerating(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/projects/${projectId}/architecture`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ forceRegenerate: force }),
|
|
||||||
});
|
|
||||||
const d = await res.json();
|
|
||||||
if (!res.ok) throw new Error(d.error || "Generation failed");
|
|
||||||
setArchitecture(d.architecture);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setConfirming(true);
|
|
||||||
try {
|
|
||||||
await fetch(`/api/projects/${projectId}/architecture`, { method: "PATCH" });
|
|
||||||
setArchitectureConfirmed(true);
|
|
||||||
} catch { /* swallow */ } finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No PRD yet
|
|
||||||
if (!prd) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
|
||||||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🔒</div>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
|
||||||
Complete your PRD first
|
|
||||||
</h3>
|
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 20 }}>
|
|
||||||
Finish your discovery conversation with Atlas, then the architect will unlock automatically.
|
|
||||||
</p>
|
|
||||||
<Link href={`/${workspace}/project/${projectId}/overview`} style={{
|
|
||||||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
|
||||||
background: "#1a1a1a", color: "#fff",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}>
|
|
||||||
Continue with Atlas →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PRD exists but no architecture yet — prompt to generate
|
|
||||||
if (!architecture) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 40, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<div style={{ textAlign: "center", maxWidth: 440 }}>
|
|
||||||
<div style={{ fontSize: "2.5rem", marginBottom: 16 }}>🏗️</div>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.4rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 10 }}>
|
|
||||||
Ready to architect {architecture ? (architecture as Architecture).productName : "your product"}
|
|
||||||
</h3>
|
|
||||||
<p style={{ fontSize: "0.84rem", color: "#6b6560", lineHeight: 1.65, marginBottom: 28, maxWidth: 380, margin: "0 auto 28px" }}>
|
|
||||||
The AI will read your PRD and recommend the technical structure — apps, services, database, and integrations. You'll review it before anything gets built.
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<div style={{ marginBottom: 16, padding: "10px 14px", background: "#fff0f0", border: "1px solid #ffcdd2", borderRadius: 8, fontSize: "0.78rem", color: "#c62828" }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(false)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "11px 28px", borderRadius: 8,
|
|
||||||
background: generating ? "#8a8478" : "#1a1a1a",
|
|
||||||
color: "#fff", border: "none", cursor: generating ? "default" : "pointer",
|
|
||||||
fontSize: "0.84rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
transition: "background 0.15s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? "Analysing PRD…" : "Generate architecture →"}
|
|
||||||
</button>
|
|
||||||
{generating && (
|
|
||||||
<p style={{ fontSize: "0.72rem", color: "#a09a90", marginTop: 12 }}>
|
|
||||||
This takes about 15–30 seconds
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Architecture loaded — show full review UI
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "28px 32px", overflow: "auto", fontFamily: "Outfit, sans-serif", maxWidth: 780 }}>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 4 }}>
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontFamily: "Newsreader, serif", fontSize: "1.35rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
|
||||||
Architecture
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: "0.75rem", color: "#a09a90", marginTop: 4 }}>
|
|
||||||
{architecture.productType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
||||||
{architectureConfirmed && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.72rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#2e7d32", background: "#2e7d3210",
|
|
||||||
border: "1px solid #a5d6a740", padding: "4px 10px", borderRadius: 5,
|
|
||||||
}}>
|
|
||||||
✓ Confirmed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(true)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "6px 14px", borderRadius: 6,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.72rem", color: "#8a8478", cursor: "pointer",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? "Regenerating…" : "Regenerate"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 18, padding: "16px 20px",
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
fontSize: "0.84rem", color: "#2a2824", lineHeight: 1.7,
|
|
||||||
}}>
|
|
||||||
{architecture.summary}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Apps */}
|
|
||||||
<SectionLabel>Apps — monorepo/apps/</SectionLabel>
|
|
||||||
{architecture.apps.map((app, i) => <AppCard key={i} app={app} />)}
|
|
||||||
|
|
||||||
{/* Packages */}
|
|
||||||
<SectionLabel>Shared packages — monorepo/packages/</SectionLabel>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
||||||
{architecture.packages.map((pkg, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
padding: "12px 16px",
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a", fontFamily: "IBM Plex Mono, monospace" }}>
|
|
||||||
packages/{pkg.name}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.76rem", color: "#8a8478", marginTop: 4, lineHeight: 1.5 }}>
|
|
||||||
{pkg.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Infrastructure */}
|
|
||||||
{architecture.infrastructure.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>Infrastructure</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.infrastructure.map((infra, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
|
||||||
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#3d5afe", background: "#3d5afe0d",
|
|
||||||
border: "1px solid #3d5afe20", padding: "2px 7px", borderRadius: 4,
|
|
||||||
flexShrink: 0, marginTop: 1,
|
|
||||||
}}>
|
|
||||||
{infra.name}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.5 }}>
|
|
||||||
{infra.reason}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Integrations */}
|
|
||||||
{architecture.integrations.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>External integrations</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.integrations.map((intg, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 8,
|
|
||||||
padding: "10px 16px", display: "flex", gap: 12, alignItems: "flex-start",
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: intg.required ? "#9a7b3a" : "#8a8478",
|
|
||||||
background: intg.required ? "#d4a04a12" : "#f6f4f0",
|
|
||||||
border: `1px solid ${intg.required ? "#d4a04a30" : "#e8e4dc"}`,
|
|
||||||
padding: "2px 7px", borderRadius: 4, flexShrink: 0, marginTop: 1,
|
|
||||||
}}>
|
|
||||||
{intg.required ? "required" : "optional"}
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.82rem", fontWeight: 600, color: "#1a1a1a" }}>{intg.name}</div>
|
|
||||||
<div style={{ fontSize: "0.75rem", color: "#8a8478", marginTop: 2 }}>{intg.notes}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Risk notes */}
|
|
||||||
{architecture.riskNotes.length > 0 && (
|
|
||||||
<>
|
|
||||||
<SectionLabel>Architecture risks</SectionLabel>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
||||||
{architecture.riskNotes.map((risk, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
background: "#fff8f0", border: "1px solid #ffe0b2",
|
|
||||||
borderRadius: 8, padding: "10px 16px",
|
|
||||||
fontSize: "0.78rem", color: "#6d4c00", lineHeight: 1.55,
|
|
||||||
display: "flex", gap: 8,
|
|
||||||
}}>
|
|
||||||
<span>⚠️</span><span>{risk}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm section */}
|
|
||||||
<div style={{
|
|
||||||
marginTop: 32, padding: "20px 24px",
|
|
||||||
background: "#fff", border: "1px solid #e8e4dc", borderRadius: 12,
|
|
||||||
borderLeft: "3px solid #1a1a1a",
|
|
||||||
}}>
|
|
||||||
{architectureConfirmed ? (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
|
||||||
✓ Architecture confirmed
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 14px" }}>
|
|
||||||
You can still regenerate or adjust the architecture before scaffolding begins. Nothing has been built yet.
|
|
||||||
</p>
|
|
||||||
<Link href={`/${workspace}/project/${projectId}/design`} style={{
|
|
||||||
display: "inline-block", padding: "9px 20px", borderRadius: 7,
|
|
||||||
background: "#1a1a1a", color: "#fff",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600, fontFamily: "Outfit, sans-serif",
|
|
||||||
textDecoration: "none",
|
|
||||||
}}>
|
|
||||||
Choose your design →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: "0.88rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 6 }}>
|
|
||||||
Does this look right?
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#6b6560", margin: "0 0 16px", lineHeight: 1.6 }}>
|
|
||||||
Review the structure above. You can regenerate if something's off, or confirm to move to design.
|
|
||||||
You can always come back and adjust before the build starts — nothing gets scaffolded yet.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", gap: 10 }}>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={confirming}
|
|
||||||
style={{
|
|
||||||
padding: "9px 22px", borderRadius: 7,
|
|
||||||
background: confirming ? "#8a8478" : "#1a1a1a",
|
|
||||||
color: "#fff", border: "none",
|
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
|
||||||
fontFamily: "Outfit, sans-serif", cursor: confirming ? "default" : "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{confirming ? "Confirming…" : "Confirm architecture →"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleGenerate(true)}
|
|
||||||
disabled={generating}
|
|
||||||
style={{
|
|
||||||
padding: "9px 18px", borderRadius: 7,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.78rem", color: "#8a8478",
|
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Regenerate
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ height: 40 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,12 @@
|
|||||||
import { ProjectShell } from "@/components/layout/project-shell";
|
/**
|
||||||
import { query } from "@/lib/db-postgres";
|
* Passthrough layout for the project route.
|
||||||
|
*
|
||||||
|
* Two sibling route groups provide their own scaffolds:
|
||||||
|
* - (home)/ — VIBNSidebar scaffold for the project home page.
|
||||||
|
* - (workspace)/ — ProjectShell (top tab nav) for overview/build/run/etc.
|
||||||
|
*/
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
interface ProjectData {
|
export default function ProjectRootLayout({ children }: { children: ReactNode }) {
|
||||||
name: string;
|
return <>{children}</>;
|
||||||
description?: string;
|
|
||||||
status?: string;
|
|
||||||
progress?: number;
|
|
||||||
discoveryPhase?: number;
|
|
||||||
capturedData?: Record<string, string>;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
featureCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getProjectData(projectId: string): Promise<ProjectData> {
|
|
||||||
try {
|
|
||||||
const rows = await query<{ data: any; created_at?: string; updated_at?: string }>(
|
|
||||||
`SELECT data, created_at, updated_at FROM fs_projects WHERE id = $1 LIMIT 1`,
|
|
||||||
[projectId]
|
|
||||||
);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
const { data, created_at, updated_at } = rows[0];
|
|
||||||
return {
|
|
||||||
name: data?.productName || data?.name || "Project",
|
|
||||||
description: data?.productVision || data?.description,
|
|
||||||
status: data?.status,
|
|
||||||
progress: data?.progress ?? 0,
|
|
||||||
discoveryPhase: data?.discoveryPhase ?? 0,
|
|
||||||
capturedData: data?.capturedData ?? {},
|
|
||||||
createdAt: created_at,
|
|
||||||
updatedAt: updated_at,
|
|
||||||
featureCount: Array.isArray(data?.features) ? data.features.length : (data?.featureCount ?? 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching project:", error);
|
|
||||||
}
|
|
||||||
return { name: "Project" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProjectLayout({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
params: Promise<{ workspace: string; projectId: string }>;
|
|
||||||
}) {
|
|
||||||
const { workspace, projectId } = await params;
|
|
||||||
const project = await getProjectData(projectId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProjectShell
|
|
||||||
workspace={workspace}
|
|
||||||
projectId={projectId}
|
|
||||||
projectName={project.name}
|
|
||||||
projectDescription={project.description}
|
|
||||||
projectStatus={project.status}
|
|
||||||
projectProgress={project.progress}
|
|
||||||
discoveryPhase={project.discoveryPhase}
|
|
||||||
capturedData={project.capturedData}
|
|
||||||
createdAt={project.createdAt}
|
|
||||||
updatedAt={project.updatedAt}
|
|
||||||
featureCount={project.featureCount}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ProjectShell>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { AtlasChat } from "@/components/AtlasChat";
|
|
||||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
const url = typeof window !== "undefined"
|
|
||||||
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
|
|
||||||
: "";
|
|
||||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShow(s => !s)}
|
|
||||||
title="Open on your phone"
|
|
||||||
style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 6,
|
|
||||||
padding: "6px 12px", borderRadius: 7,
|
|
||||||
background: "none", border: "1px solid #e0dcd4",
|
|
||||||
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
|
|
||||||
color: "#8a8478", cursor: "pointer",
|
|
||||||
transition: "border-color 0.12s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
|
||||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
|
||||||
>
|
|
||||||
📱 Open on phone
|
|
||||||
</button>
|
|
||||||
{show && (
|
|
||||||
<div style={{
|
|
||||||
position: "absolute", top: "calc(100% + 8px)", right: 0,
|
|
||||||
background: "#fff", borderRadius: 12,
|
|
||||||
border: "1px solid #e8e4dc",
|
|
||||||
boxShadow: "0 8px 24px #1a1a1a12",
|
|
||||||
padding: "16px", zIndex: 50,
|
|
||||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
|
||||||
minWidth: 220,
|
|
||||||
}}>
|
|
||||||
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
|
|
||||||
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
Scan to open Atlas on your phone
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
|
|
||||||
{url}
|
|
||||||
</p>
|
|
||||||
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Project {
|
|
||||||
id: string;
|
|
||||||
productName: string;
|
|
||||||
stage?: "discovery" | "architecture" | "building" | "active";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectOverviewPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const workspace = params.workspace as string;
|
|
||||||
const { status: authStatus } = useSession();
|
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (authStatus !== "authenticated") {
|
|
||||||
if (authStatus === "unauthenticated") setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`/api/projects/${projectId}`)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d) => setProject(d.project))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [authStatus, projectId]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
<Loader2 style={{ width: 24, height: 24, color: "#a09a90" }} className="animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90", fontSize: "0.88rem" }}>
|
|
||||||
Project not found.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
||||||
{/* Desktop-only: Open on phone button */}
|
|
||||||
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
|
|
||||||
<div className="vibn-phone-btn" style={{
|
|
||||||
position: "absolute", top: 14, right: 248,
|
|
||||||
zIndex: 20,
|
|
||||||
}}>
|
|
||||||
<MobileQRButton projectId={projectId} workspace={workspace} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AtlasChat
|
|
||||||
projectId={projectId}
|
|
||||||
projectName={project.productName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
|
|
||||||
// Maps each PRD section to the discovery phase that populates it
|
|
||||||
const PRD_SECTIONS = [
|
|
||||||
{ id: "executive_summary", label: "Executive Summary", phaseId: "big_picture" },
|
|
||||||
{ id: "problem_statement", label: "Problem Statement", phaseId: "big_picture" },
|
|
||||||
{ id: "vision_metrics", label: "Vision & Success Metrics", phaseId: "big_picture" },
|
|
||||||
{ id: "users_personas", label: "Users & Personas", phaseId: "users_personas" },
|
|
||||||
{ id: "user_flows", label: "User Flows", phaseId: "users_personas" },
|
|
||||||
{ id: "feature_requirements", label: "Feature Requirements", phaseId: "features_scope" },
|
|
||||||
{ id: "screen_specs", label: "Screen Specs", phaseId: "screens_data" },
|
|
||||||
{ id: "business_model", label: "Business Model", phaseId: "business_model" },
|
|
||||||
{ id: "integrations", label: "Integrations & Dependencies", phaseId: "features_scope" },
|
|
||||||
{ id: "non_functional", label: "Non-Functional Reqs", phaseId: null },
|
|
||||||
{ id: "risks", label: "Risks & Mitigations", phaseId: "risks_questions" },
|
|
||||||
{ id: "open_questions", label: "Open Questions", phaseId: "risks_questions" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SavedPhase {
|
|
||||||
phase: string;
|
|
||||||
title: string;
|
|
||||||
summary: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
saved_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatValue(v: unknown): string {
|
|
||||||
if (v === null || v === undefined) return "—";
|
|
||||||
if (Array.isArray(v)) return v.map(item => typeof item === "object" ? JSON.stringify(item) : String(item)).join(", ");
|
|
||||||
return String(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PhaseDataCard({ phase }: { phase: SavedPhase }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const entries = Object.entries(phase.data).filter(([, v]) => v !== null && v !== undefined && v !== "");
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 10, background: "#f6f4f0", borderRadius: 8,
|
|
||||||
border: "1px solid #e8e4dc", overflow: "hidden",
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(e => !e)}
|
|
||||||
style={{
|
|
||||||
width: "100%", textAlign: "left", padding: "10px 14px",
|
|
||||||
background: "none", border: "none", cursor: "pointer",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
||||||
fontFamily: "Outfit, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "0.78rem", color: "#4a4640", lineHeight: 1.45 }}>
|
|
||||||
{phase.summary}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: "0.7rem", color: "#a09a90", marginLeft: 8, flexShrink: 0 }}>
|
|
||||||
{expanded ? "▲" : "▼"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{expanded && entries.length > 0 && (
|
|
||||||
<div style={{ padding: "4px 14px 14px", borderTop: "1px solid #e8e4dc" }}>
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<div key={k} style={{ marginTop: 10 }}>
|
|
||||||
<div style={{
|
|
||||||
fontSize: "0.6rem", color: "#b5b0a6", textTransform: "uppercase",
|
|
||||||
letterSpacing: "0.06em", fontWeight: 600, marginBottom: 2,
|
|
||||||
}}>
|
|
||||||
{k.replace(/_/g, " ")}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "0.78rem", color: "#2a2824", lineHeight: 1.5 }}>
|
|
||||||
{formatValue(v)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PRDPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const projectId = params.projectId as string;
|
|
||||||
const [prd, setPrd] = useState<string | null>(null);
|
|
||||||
const [savedPhases, setSavedPhases] = useState<SavedPhase[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
Promise.all([
|
|
||||||
fetch(`/api/projects/${projectId}`).then(r => r.json()).catch(() => ({})),
|
|
||||||
fetch(`/api/projects/${projectId}/save-phase`).then(r => r.json()).catch(() => ({ phases: [] })),
|
|
||||||
]).then(([projectData, phaseData]) => {
|
|
||||||
setPrd(projectData?.project?.prd ?? null);
|
|
||||||
setSavedPhases(phaseData?.phases ?? []);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const phaseMap = new Map(savedPhases.map(p => [p.phase, p]));
|
|
||||||
const savedPhaseIds = new Set(savedPhases.map(p => p.phase));
|
|
||||||
|
|
||||||
const sections = PRD_SECTIONS.map(s => ({
|
|
||||||
...s,
|
|
||||||
savedPhase: s.phaseId ? phaseMap.get(s.phaseId) ?? null : null,
|
|
||||||
isDone: s.phaseId ? savedPhaseIds.has(s.phaseId) : false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const doneCount = sections.filter(s => s.isDone).length;
|
|
||||||
const totalPct = Math.round((doneCount / sections.length) * 100);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit, sans-serif", color: "#a09a90" }}>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "28px 32px", flex: 1, overflow: "auto", fontFamily: "Outfit, sans-serif" }}>
|
|
||||||
{prd ? (
|
|
||||||
/* ── Finalized PRD view ── */
|
|
||||||
<div style={{ maxWidth: 760 }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 20 }}>
|
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", margin: 0 }}>
|
|
||||||
Product Requirements
|
|
||||||
</h3>
|
|
||||||
<span style={{ fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", color: "#6b6560", background: "#f0ece4", padding: "4px 10px", borderRadius: 5 }}>
|
|
||||||
PRD complete
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
background: "#fff", borderRadius: 10, border: "1px solid #e8e4dc",
|
|
||||||
padding: "28px 32px", lineHeight: 1.8,
|
|
||||||
fontSize: "0.88rem", color: "#2a2824",
|
|
||||||
whiteSpace: "pre-wrap", fontFamily: "Outfit, sans-serif",
|
|
||||||
}}>
|
|
||||||
{prd}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* ── Section progress view ── */
|
|
||||||
<div style={{ maxWidth: 680 }}>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div style={{
|
|
||||||
display: "flex", alignItems: "center", gap: 16,
|
|
||||||
padding: "16px 20px", background: "#fff",
|
|
||||||
border: "1px solid #e8e4dc", borderRadius: 10,
|
|
||||||
marginBottom: 24, boxShadow: "0 1px 2px #1a1a1a05",
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
fontSize: "1.4rem", fontWeight: 500, color: "#1a1a1a", minWidth: 52,
|
|
||||||
}}>
|
|
||||||
{totalPct}%
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ height: 4, borderRadius: 2, background: "#eae6de" }}>
|
|
||||||
<div style={{
|
|
||||||
height: "100%", borderRadius: 2,
|
|
||||||
width: `${totalPct}%`, background: "#1a1a1a",
|
|
||||||
transition: "width 0.6s ease",
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: "0.75rem", color: "#a09a90" }}>
|
|
||||||
{doneCount}/{sections.length} sections
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sections */}
|
|
||||||
{sections.map((s, i) => (
|
|
||||||
<div
|
|
||||||
key={s.id}
|
|
||||||
style={{
|
|
||||||
padding: "14px 18px", marginBottom: 6,
|
|
||||||
background: "#fff", borderRadius: 10,
|
|
||||||
border: `1px solid ${s.isDone ? "#a5d6a740" : "#e8e4dc"}`,
|
|
||||||
animationDelay: `${i * 0.04}s`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
||||||
{/* Status icon */}
|
|
||||||
<div style={{
|
|
||||||
width: 24, height: 24, borderRadius: 6, flexShrink: 0,
|
|
||||||
background: s.isDone ? "#2e7d3210" : "#f6f4f0",
|
|
||||||
display: "flex", alignItems: "center", justifyContent: "center",
|
|
||||||
fontSize: "0.65rem", fontWeight: 700,
|
|
||||||
color: s.isDone ? "#2e7d32" : "#c5c0b8",
|
|
||||||
}}>
|
|
||||||
{s.isDone ? "✓" : "○"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
flex: 1, fontSize: "0.84rem",
|
|
||||||
color: s.isDone ? "#1a1a1a" : "#a09a90",
|
|
||||||
fontWeight: s.isDone ? 500 : 400,
|
|
||||||
}}>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{s.isDone && s.savedPhase && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#2e7d32", background: "#2e7d3210",
|
|
||||||
padding: "2px 7px", borderRadius: 4, fontWeight: 500,
|
|
||||||
}}>
|
|
||||||
saved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!s.isDone && !s.phaseId && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: "0.65rem", fontFamily: "IBM Plex Mono, monospace",
|
|
||||||
color: "#b5b0a6", padding: "2px 7px",
|
|
||||||
}}>
|
|
||||||
generated
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expandable phase data */}
|
|
||||||
{s.isDone && s.savedPhase && (
|
|
||||||
<PhaseDataCard phase={s.savedPhase} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pending hint */}
|
|
||||||
{!s.isDone && (
|
|
||||||
<div style={{ marginTop: 6, marginLeft: 36, fontSize: "0.72rem", color: "#c5c0b8" }}>
|
|
||||||
{s.phaseId
|
|
||||||
? `Complete the ${s.savedPhase ? s.savedPhase.title : "discovery"} phase in Atlas`
|
|
||||||
: "Will be generated when PRD is finalized"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{doneCount === 0 && (
|
|
||||||
<p style={{ fontSize: "0.78rem", color: "#b5b0a6", marginTop: 20, textAlign: "center" }}>
|
|
||||||
Continue chatting with Atlas — saved phases will appear here automatically.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -59,7 +59,7 @@ function StatusTag({ status }: { status?: string }) {
|
|||||||
display: "inline-flex", alignItems: "center", gap: 5,
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
padding: "3px 9px", borderRadius: 4,
|
padding: "3px 9px", borderRadius: 4,
|
||||||
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
fontSize: "0.68rem", fontWeight: 600, letterSpacing: "0.02em",
|
||||||
color, background: bg, fontFamily: "Outfit, sans-serif",
|
color, background: bg, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
}}>
|
}}>
|
||||||
<StatusDot status={status} /> {label}
|
<StatusDot status={status} /> {label}
|
||||||
</span>
|
</span>
|
||||||
@@ -76,6 +76,7 @@ export default function ProjectsPage() {
|
|||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
const [projectToDelete, setProjectToDelete] = useState<ProjectWithStats | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -134,13 +135,13 @@ export default function ProjectsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="vibn-enter"
|
className="vibn-enter"
|
||||||
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "Outfit, sans-serif" }}
|
style={{ padding: "44px 52px", maxWidth: 900, fontFamily: "var(--font-inter), ui-sans-serif, sans-serif" }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: 36 }}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontFamily: "Newsreader, serif", fontSize: "1.9rem",
|
fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.9rem",
|
||||||
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
fontWeight: 400, color: "#1a1a1a", letterSpacing: "-0.03em",
|
||||||
lineHeight: 1.15, marginBottom: 4,
|
lineHeight: 1.15, marginBottom: 4,
|
||||||
}}>
|
}}>
|
||||||
@@ -158,7 +159,7 @@ export default function ProjectsPage() {
|
|||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
border: "1px solid #1a1a1a",
|
border: "1px solid #1a1a1a",
|
||||||
fontSize: "0.78rem", fontWeight: 600,
|
fontSize: "0.78rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
<span style={{ fontSize: "1rem", lineHeight: 1, fontWeight: 300 }}>+</span>
|
||||||
@@ -183,20 +184,22 @@ export default function ProjectsPage() {
|
|||||||
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
|
style={{ position: "relative", animationDelay: `${i * 0.05}s` }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspace}/project/${p.id}/overview`}
|
href={`/${workspace}/project/${p.id}`}
|
||||||
style={{
|
style={{
|
||||||
width: "100%", display: "flex", alignItems: "center",
|
width: "100%", display: "flex", alignItems: "center",
|
||||||
padding: "18px 22px", borderRadius: 10,
|
padding: "18px 22px", borderRadius: 10,
|
||||||
background: "#fff", border: "1px solid #e8e4dc",
|
background: "#fff", border: "1px solid #e8e4dc",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
textDecoration: "none", boxShadow: "0 1px 2px #1a1a1a05",
|
textDecoration: "none", boxShadow: "0 1px 2px #1a1a1a05",
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
setHoveredId(p.id);
|
||||||
e.currentTarget.style.borderColor = "#d0ccc4";
|
e.currentTarget.style.borderColor = "#d0ccc4";
|
||||||
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
e.currentTarget.style.boxShadow = "0 2px 8px #1a1a1a0a";
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
setHoveredId(null);
|
||||||
e.currentTarget.style.borderColor = "#e8e4dc";
|
e.currentTarget.style.borderColor = "#e8e4dc";
|
||||||
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
e.currentTarget.style.boxShadow = "0 1px 2px #1a1a1a05";
|
||||||
}}
|
}}
|
||||||
@@ -209,7 +212,7 @@ export default function ProjectsPage() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: "Newsreader, serif",
|
fontFamily: "var(--font-lora), ui-serif, serif",
|
||||||
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
fontSize: "1.05rem", fontWeight: 500, color: "#1a1a1a",
|
||||||
}}>
|
}}>
|
||||||
{p.productName[0]?.toUpperCase() ?? "P"}
|
{p.productName[0]?.toUpperCase() ?? "P"}
|
||||||
@@ -247,19 +250,19 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete (hover) */}
|
{/* Delete (visible on row hover) */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); setProjectToDelete(p); }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectToDelete(p); }}
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 16, padding: "5px 8px", borderRadius: 6,
|
marginLeft: 16, padding: "6px 8px", borderRadius: 6,
|
||||||
border: "none", background: "transparent",
|
border: "none", background: "transparent",
|
||||||
color: "#b5b0a6", cursor: "pointer",
|
color: "#c0bab2", cursor: "pointer",
|
||||||
opacity: 0, transition: "opacity 0.15s",
|
opacity: hoveredId === p.id ? 1 : 0,
|
||||||
fontFamily: "Outfit, sans-serif",
|
transition: "opacity 0.15s, color 0.15s",
|
||||||
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
className="delete-btn"
|
onMouseEnter={(e) => { e.currentTarget.style.color = "#d32f2f"; }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.color = "#d32f2f"}
|
onMouseLeave={(e) => { e.currentTarget.style.color = "#c0bab2"; }}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.color = "#b5b0a6"}
|
|
||||||
title="Delete project"
|
title="Delete project"
|
||||||
>
|
>
|
||||||
<Trash2 style={{ width: 14, height: 14 }} />
|
<Trash2 style={{ width: 14, height: 14 }} />
|
||||||
@@ -275,7 +278,7 @@ export default function ProjectsPage() {
|
|||||||
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
width: "100%", display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
padding: "22px", borderRadius: 10,
|
padding: "22px", borderRadius: 10,
|
||||||
background: "transparent", border: "1px dashed #d0ccc4",
|
background: "transparent", border: "1px dashed #d0ccc4",
|
||||||
cursor: "pointer", fontFamily: "Outfit, sans-serif",
|
cursor: "pointer", fontFamily: "var(--font-inter), ui-sans-serif, sans-serif",
|
||||||
color: "#b5b0a6", fontSize: "0.84rem", fontWeight: 500,
|
color: "#b5b0a6", fontSize: "0.84rem", fontWeight: 500,
|
||||||
transition: "all 0.15s",
|
transition: "all 0.15s",
|
||||||
animationDelay: `${projects.length * 0.05}s`,
|
animationDelay: `${projects.length * 0.05}s`,
|
||||||
@@ -292,11 +295,11 @@ export default function ProjectsPage() {
|
|||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!loading && projects.length === 0 && (
|
{!loading && projects.length === 0 && (
|
||||||
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
<div style={{ textAlign: "center", paddingTop: 64 }}>
|
||||||
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
<h3 style={{ fontFamily: "var(--font-lora), ui-serif, serif", fontSize: "1.3rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 8 }}>
|
||||||
No projects yet
|
No projects yet
|
||||||
</h3>
|
</h3>
|
||||||
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
|
<p style={{ fontSize: "0.82rem", color: "#a09a90", lineHeight: 1.6, marginBottom: 24 }}>
|
||||||
Tell Atlas what you want to build and it will figure out the rest.
|
Tell Vibn what you want to build and it will figure out the rest.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNew(true)}
|
onClick={() => setShowNew(true)}
|
||||||
@@ -304,7 +307,7 @@ export default function ProjectsPage() {
|
|||||||
padding: "10px 22px", borderRadius: 7,
|
padding: "10px 22px", borderRadius: 7,
|
||||||
background: "#1a1a1a", color: "#fff",
|
background: "#1a1a1a", color: "#fff",
|
||||||
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
border: "none", fontSize: "0.84rem", fontWeight: 600,
|
||||||
fontFamily: "Outfit, sans-serif", cursor: "pointer",
|
fontFamily: "var(--font-inter), ui-sans-serif, sans-serif", cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create your first project
|
Create your first project
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { auth } from '@/lib/firebase/config';
|
import { useSession } from 'next-auth/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
|
import { Settings, User, Bell, Shield, Trash2 } from 'lucide-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { WorkspaceKeysPanel } from '@/components/workspace/WorkspaceKeysPanel';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -31,6 +32,7 @@ export default function SettingsPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspace = params.workspace as string;
|
const workspace = params.workspace as string;
|
||||||
|
const { data: session, status } = useSession();
|
||||||
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
|
const [settings, setSettings] = useState<WorkspaceSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -38,51 +40,19 @@ export default function SettingsPage() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
if (status === 'loading') return;
|
||||||
loadUserProfile();
|
setDisplayName(session?.user?.name ?? '');
|
||||||
}, []);
|
setEmail(session?.user?.email ?? '');
|
||||||
|
setLoading(false);
|
||||||
const loadSettings = async () => {
|
}, [session, status]);
|
||||||
try {
|
|
||||||
const user = auth.currentUser;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const token = await user.getIdToken();
|
|
||||||
const response = await fetch(`/api/workspace/${workspace}/settings`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading settings:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadUserProfile = () => {
|
|
||||||
const user = auth.currentUser;
|
|
||||||
if (user) {
|
|
||||||
setDisplayName(user.displayName || '');
|
|
||||||
setEmail(user.email || '');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const user = auth.currentUser;
|
if (!session?.user) {
|
||||||
if (!user) {
|
|
||||||
toast.error('Please sign in');
|
toast.error('Please sign in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update profile logic would go here
|
|
||||||
toast.success('Profile updated successfully');
|
toast.success('Profile updated successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving profile:', error);
|
console.error('Error saving profile:', error);
|
||||||
@@ -177,6 +147,9 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Workspace tenancy + AI access keys */}
|
||||||
|
<WorkspaceKeysPanel workspaceSlug={workspace} />
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
307
app/api/admin/migrate/route.ts
Normal file
307
app/api/admin/migrate/route.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/admin/migrate
|
||||||
|
*
|
||||||
|
* One-shot migration endpoint. Requires the ADMIN_MIGRATE_SECRET env var
|
||||||
|
* to be set and passed as x-admin-secret header (or ?secret= query param).
|
||||||
|
*
|
||||||
|
* Idempotent — safe to call multiple times (all statements use IF NOT EXISTS).
|
||||||
|
*
|
||||||
|
* curl -X POST https://vibnai.com/api/admin/migrate \
|
||||||
|
* -H "x-admin-secret: <ADMIN_MIGRATE_SECRET>"
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const secret = process.env.ADMIN_MIGRATE_SECRET ?? "";
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incoming =
|
||||||
|
req.headers.get("x-admin-secret") ??
|
||||||
|
new URL(req.url).searchParams.get("secret") ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (incoming !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Array<{ statement: string; ok: boolean; error?: string }> = [];
|
||||||
|
|
||||||
|
// Inline the DDL so this works even if the SQL file isn't on the runtime fs
|
||||||
|
const statements = [
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_users_email_idx ON fs_users ((data->>'email'))`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_users_user_id_idx ON fs_users (user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
workspace TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_user_idx ON fs_projects (user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx ON fs_projects (workspace)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS fs_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_sessions_user_idx ON fs_sessions (user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_sessions_project_idx ON fs_sessions ((data->>'projectId'))`,
|
||||||
|
|
||||||
|
// agent_sessions uses TEXT for project_id to match fs_projects.id
|
||||||
|
`CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
app_name TEXT NOT NULL,
|
||||||
|
app_path TEXT NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
plan JSONB,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
output JSONB NOT NULL DEFAULT '[]',
|
||||||
|
changed_files JSONB NOT NULL DEFAULT '[]',
|
||||||
|
error TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS agent_session_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
ts TIMESTAMPTZ NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
client_event_id UUID UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(session_id, seq)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq)`,
|
||||||
|
|
||||||
|
// NextAuth / Prisma tables
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
email_verified TIMESTAMPTZ,
|
||||||
|
image TEXT
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_account_id TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
access_token TEXT,
|
||||||
|
expires_at INTEGER,
|
||||||
|
token_type TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
session_state TEXT,
|
||||||
|
UNIQUE (provider, provider_account_id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires TIMESTAMPTZ NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
expires TIMESTAMPTZ NOT NULL,
|
||||||
|
UNIQUE (identifier, token)
|
||||||
|
)`,
|
||||||
|
|
||||||
|
// ── Vibn workspaces (logical tenancy on top of Coolify) ──────────
|
||||||
|
// One workspace per Vibn account. Holds a Coolify Project UUID
|
||||||
|
// (the team boundary inside Coolify) and a Gitea org name.
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspaces (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT NOT NULL,
|
||||||
|
coolify_project_uuid TEXT,
|
||||||
|
coolify_team_id INT,
|
||||||
|
gitea_org TEXT,
|
||||||
|
provision_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
provision_error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspaces_owner_idx ON vibn_workspaces (owner_user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspace_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (workspace_id, user_id)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspace_members_user_idx ON vibn_workspace_members (user_id)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_workspace_api_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["workspace:*"]'::jsonb,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_workspace_api_keys_workspace_idx ON vibn_workspace_api_keys (workspace_id)`,
|
||||||
|
|
||||||
|
// Tag projects with the workspace they belong to (nullable until backfill).
|
||||||
|
// The pre-existing fs_projects.workspace TEXT column stays for the legacy slug;
|
||||||
|
// this new UUID FK is the canonical link.
|
||||||
|
`ALTER TABLE fs_projects ADD COLUMN IF NOT EXISTS vibn_workspace_id UUID REFERENCES vibn_workspaces(id) ON DELETE SET NULL`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS fs_projects_vibn_workspace_idx ON fs_projects (vibn_workspace_id)`,
|
||||||
|
|
||||||
|
// ── Per-workspace Gitea bot user (for direct AI access) ──────────
|
||||||
|
// Each workspace gets its own Gitea user with a PAT scoped to the
|
||||||
|
// workspace's org, so AI agents can `git clone` / push directly
|
||||||
|
// without ever touching the root admin token.
|
||||||
|
//
|
||||||
|
// Token is encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
|
||||||
|
// Layout: iv(12) || ciphertext || authTag(16), base64-encoded.
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_username TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_user_id INT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_token_encrypted TEXT`,
|
||||||
|
|
||||||
|
// ── Phase 4: workspace-owned deploy infra ────────────────────────
|
||||||
|
// Lets AI agents create Coolify applications/databases/services
|
||||||
|
// against a Gitea repo the bot can read, routed to the right
|
||||||
|
// server and Docker destination, and exposed under the workspace's
|
||||||
|
// own subdomain namespace.
|
||||||
|
//
|
||||||
|
// coolify_server_uuid — which Coolify server the workspace deploys to
|
||||||
|
// coolify_destination_uuid — Docker network / destination on that server
|
||||||
|
// coolify_environment_name — Coolify environment (default "production")
|
||||||
|
// coolify_private_key_uuid — workspace-wide SSH deploy key (Coolify-side UUID)
|
||||||
|
// gitea_bot_ssh_key_id — Gitea key id for the matching public key (for rotation)
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_server_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_destination_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_environment_name TEXT NOT NULL DEFAULT 'production'`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS coolify_private_key_uuid TEXT`,
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS gitea_bot_ssh_key_id INT`,
|
||||||
|
|
||||||
|
// ── Phase 5.1: domains (OpenSRS) + DNS + billing ledger ──────────
|
||||||
|
//
|
||||||
|
// vibn_domains — owned domains + their registration lifecycle
|
||||||
|
// vibn_domain_events — audit trail (register, attach, renew, expire)
|
||||||
|
// vibn_billing_ledger — money in/out at the workspace level
|
||||||
|
//
|
||||||
|
// Reg credentials for a domain (OpenSRS manage-user password) are
|
||||||
|
// encrypted at rest with AES-256-GCM using VIBN_SECRETS_KEY.
|
||||||
|
//
|
||||||
|
// Workspace residency preference for DNS:
|
||||||
|
// dns_provider = 'cloud_dns' (default, public records)
|
||||||
|
// dns_provider = 'cira_dzone' (strict Canadian residency, future)
|
||||||
|
`ALTER TABLE vibn_workspaces ADD COLUMN IF NOT EXISTS dns_provider TEXT NOT NULL DEFAULT 'cloud_dns'`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_domains (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
tld TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
registrar TEXT NOT NULL DEFAULT 'opensrs',
|
||||||
|
registrar_order_id TEXT,
|
||||||
|
registrar_username TEXT,
|
||||||
|
registrar_password_enc TEXT,
|
||||||
|
period_years INT NOT NULL DEFAULT 1,
|
||||||
|
whois_privacy BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
registered_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
dns_provider TEXT,
|
||||||
|
dns_zone_id TEXT,
|
||||||
|
dns_nameservers JSONB,
|
||||||
|
last_reconciled_at TIMESTAMPTZ,
|
||||||
|
price_paid_cents INT,
|
||||||
|
price_currency TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (domain)
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_workspace_idx ON vibn_domains (workspace_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_status_idx ON vibn_domains (status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domains_expires_idx ON vibn_domains (expires_at) WHERE expires_at IS NOT NULL`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_domain_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain_id UUID NOT NULL REFERENCES vibn_domains(id) ON DELETE CASCADE,
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_domain_events_domain_idx ON vibn_domain_events (domain_id, created_at DESC)`,
|
||||||
|
|
||||||
|
`CREATE TABLE IF NOT EXISTS vibn_billing_ledger (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES vibn_workspaces(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
amount_cents INT NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||||
|
ref_type TEXT,
|
||||||
|
ref_id TEXT,
|
||||||
|
note TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_workspace_idx ON vibn_billing_ledger (workspace_id, created_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS vibn_billing_ledger_ref_idx ON vibn_billing_ledger (ref_type, ref_id)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const stmt of statements) {
|
||||||
|
const label = stmt.trim().split("\n")[0].trim().slice(0, 80);
|
||||||
|
try {
|
||||||
|
await query(stmt, []);
|
||||||
|
results.push({ statement: label, ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({
|
||||||
|
statement: label,
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failed = results.filter(r => !r.ok);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: failed.length === 0, results },
|
||||||
|
{ status: failed.length === 0 ? 200 : 207 }
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/api/debug/prisma/route.ts
Normal file
40
app/api/debug/prisma/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only: verifies Prisma can connect (NextAuth adapter needs this after Google redirects back).
|
||||||
|
* Open GET /api/debug/prisma while running next dev.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUrl = Boolean(process.env.DATABASE_URL?.trim() || process.env.POSTGRES_URL?.trim());
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
databaseUrlConfigured: hasUrl,
|
||||||
|
hint: "Prisma connects; if auth still fails, check Google client id/secret and terminal [next-auth] logs.",
|
||||||
|
});
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
const publicHost = /Can't reach database server at `([\d.]+):(\d+)`/.exec(message);
|
||||||
|
const hint = publicHost
|
||||||
|
? `No TCP route to Postgres at ${publicHost[1]}:${publicHost[2]} from this machine. In Coolify: confirm the DB service publishes that host port and Postgres listens on 0.0.0.0. On the cloud firewall (e.g. GCP), allow inbound TCP ${publicHost[2]} from your IP (or use VPN). Test: nc -zv ${publicHost[1]} ${publicHost[2]} or psql. Then npm run db:push from vibn-frontend.`
|
||||||
|
: "If the URL uses a Coolify internal hostname, it only works inside Docker. Otherwise check DATABASE_URL, firewall, and run npm run db:push.";
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
databaseUrlConfigured: hasUrl,
|
||||||
|
message,
|
||||||
|
hint,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
2395
app/api/mcp/route.ts
2395
app/api/mcp/route.ts
File diff suppressed because it is too large
Load Diff
201
app/api/projects/[projectId]/advisor/route.ts
Normal file
201
app/api/projects/[projectId]/advisor/route.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Assist COO — proxies to the agent runner's Orchestrator.
|
||||||
|
*
|
||||||
|
* The Orchestrator (Claude Sonnet 4.6, Tier B) has full tool access:
|
||||||
|
* Gitea — read repos, files, issues, commits
|
||||||
|
* Coolify — app status, deploy logs, trigger deploys
|
||||||
|
* Web search, memory, agent spawning
|
||||||
|
*
|
||||||
|
* This route loads project-specific context (PRD, phases, apps, sessions)
|
||||||
|
* and injects it as knowledge_context into the orchestrator's system prompt.
|
||||||
|
*/
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'https://agents.vibnai.com';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context loader — everything the COO needs to know about the project
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildKnowledgeContext(projectId: string, email: string): Promise<string> {
|
||||||
|
const [projectRows, phaseRows, sessionRows] = await Promise.all([
|
||||||
|
query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, email]
|
||||||
|
).catch(() => [] as { data: Record<string, unknown> }[]),
|
||||||
|
query<{ phase: string; title: string; summary: string }>(
|
||||||
|
`SELECT phase, title, summary FROM atlas_phases
|
||||||
|
WHERE project_id = $1 ORDER BY saved_at ASC`,
|
||||||
|
[projectId]
|
||||||
|
).catch(() => [] as { phase: string; title: string; summary: string }[]),
|
||||||
|
query<{ task: string; status: string }>(
|
||||||
|
`SELECT data->>'task' as task, data->>'status' as status
|
||||||
|
FROM fs_sessions WHERE data->>'projectId' = $1
|
||||||
|
ORDER BY created_at DESC LIMIT 8`,
|
||||||
|
[projectId]
|
||||||
|
).catch(() => [] as { task: string; status: string }[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const d = projectRows[0]?.data ?? {};
|
||||||
|
const name = (d.name as string) ?? 'Unknown Project';
|
||||||
|
const vision = (d.productVision as string) ?? (d.vision as string) ?? '';
|
||||||
|
const giteaRepo = (d.giteaRepo as string) ?? '';
|
||||||
|
const prd = (d.prd as string) ?? '';
|
||||||
|
const architecture = d.architecture as Record<string, unknown> | null ?? null;
|
||||||
|
const apps = (d.apps as Array<{ name: string; domain?: string; coolifyServiceUuid?: string }>) ?? [];
|
||||||
|
const coolifyProjectUuid = (d.coolifyProjectUuid as string) ?? '';
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// COO persona — injected so the orchestrator knows its role for this session
|
||||||
|
lines.push(`## Your role for this conversation
|
||||||
|
You are the personal AI COO for "${name}" — a trusted executive partner to the founder.
|
||||||
|
The founder talks to you. You figure out what needs to happen and get it done.
|
||||||
|
You delegate to specialist agents (Coder, PM, Marketing) when work is needed.
|
||||||
|
|
||||||
|
Operating principles:
|
||||||
|
- Use your tools proactively. Don't guess — check Gitea for what's been built, check Coolify for app status.
|
||||||
|
- Before delegating any work: state the scope in plain English and confirm with the founder.
|
||||||
|
- Be brief. No preamble, no "Great question!".
|
||||||
|
- You decide the technical approach — never ask the founder to choose.
|
||||||
|
- Be honest when you're uncertain or when data isn't available.
|
||||||
|
- Do NOT spawn agents on the protected platform repos (vibn-frontend, vibn-agent-runner, vibn-api, master-ai).`);
|
||||||
|
|
||||||
|
// Project identity
|
||||||
|
lines.push(`\n## Project: ${name}`);
|
||||||
|
if (vision) lines.push(`Vision: ${vision}`);
|
||||||
|
if (giteaRepo) lines.push(`Gitea repo: ${giteaRepo} — use read_repo_file and list_repos to explore it`);
|
||||||
|
if (coolifyProjectUuid) lines.push(`Coolify project UUID: ${coolifyProjectUuid} — use coolify_list_applications to find its apps`);
|
||||||
|
|
||||||
|
// Architecture document
|
||||||
|
if (architecture) {
|
||||||
|
const archApps = (architecture.apps as Array<{ name: string; type: string; description: string }> ?? [])
|
||||||
|
.map(a => ` - ${a.name} (${a.type}): ${a.description}`).join('\n');
|
||||||
|
const archInfra = (architecture.infrastructure as Array<{ name: string; reason: string }> ?? [])
|
||||||
|
.map(i => ` - ${i.name}: ${i.reason}`).join('\n');
|
||||||
|
lines.push(`\n## Technical Architecture\nSummary: ${architecture.summary ?? ''}\n\nApps:\n${archApps}\n\nInfrastructure:\n${archInfra}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRD or discovery phases
|
||||||
|
if (prd) {
|
||||||
|
// Claude Sonnet has a 200k token context — pass the full PRD, no truncation needed
|
||||||
|
lines.push(`\n## Product Requirements Document\n${prd}`);
|
||||||
|
} else if (phaseRows.length > 0) {
|
||||||
|
lines.push(`\n## Discovery phases completed (${phaseRows.length})`);
|
||||||
|
for (const p of phaseRows) {
|
||||||
|
lines.push(`- ${p.title}: ${p.summary}`);
|
||||||
|
}
|
||||||
|
lines.push(`(PRD not yet finalized — Vibn discovery is in progress)`);
|
||||||
|
} else {
|
||||||
|
lines.push(`\n## Product discovery: not yet started`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deployed apps
|
||||||
|
if (apps.length > 0) {
|
||||||
|
lines.push(`\n## Deployed apps`);
|
||||||
|
for (const a of apps) {
|
||||||
|
const url = a.domain ? `https://${a.domain}` : '(no domain yet)';
|
||||||
|
const uuid = a.coolifyServiceUuid ? ` [uuid: ${a.coolifyServiceUuid}]` : '';
|
||||||
|
lines.push(`- ${a.name} → ${url}${uuid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent agent work
|
||||||
|
const validSessions = sessionRows.filter(s => s.task);
|
||||||
|
if (validSessions.length > 0) {
|
||||||
|
lines.push(`\n## Recent agent sessions (what's been worked on)`);
|
||||||
|
for (const s of validSessions) {
|
||||||
|
lines.push(`- [${s.status ?? 'unknown'}] ${s.task}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message, history = [] } = await req.json() as {
|
||||||
|
message: string;
|
||||||
|
history: Array<{ role: 'user' | 'model'; content: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!message?.trim()) {
|
||||||
|
return new Response('Message required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project context (best-effort)
|
||||||
|
let knowledgeContext = '';
|
||||||
|
try {
|
||||||
|
knowledgeContext = await buildKnowledgeContext(projectId, session.user.email);
|
||||||
|
} catch { /* proceed without — orchestrator still works */ }
|
||||||
|
|
||||||
|
// Convert history: frontend uses "model", orchestrator uses "assistant"
|
||||||
|
const llmHistory = history
|
||||||
|
.filter(h => h.content?.trim())
|
||||||
|
.map(h => ({
|
||||||
|
role: (h.role === 'model' ? 'assistant' : 'user') as 'assistant' | 'user',
|
||||||
|
content: h.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call the orchestrator on the agent runner
|
||||||
|
let orchRes: Response;
|
||||||
|
try {
|
||||||
|
orchRes = await fetch(`${AGENT_RUNNER_URL}/orchestrator/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message,
|
||||||
|
// Scoped session per project so in-memory context persists within a browser session
|
||||||
|
session_id: `coo_${projectId}_${session.user.email.split('@')[0]}`,
|
||||||
|
history: llmHistory,
|
||||||
|
knowledge_context: knowledgeContext,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return new Response(`Agent runner unreachable: ${msg}`, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orchRes.ok) {
|
||||||
|
const err = await orchRes.text();
|
||||||
|
return new Response(`Orchestrator error: ${err}`, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await orchRes.json() as { reply?: string; error?: string };
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return new Response(result.error, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = result.reply ?? '(no response)';
|
||||||
|
|
||||||
|
// Return as a streaming response — single chunk (orchestrator is non-streaming)
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(reply));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from "@/lib/auth/authOptions";
|
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
@@ -87,7 +86,7 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -114,7 +113,6 @@ export async function POST(
|
|||||||
p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null,
|
p.giteaRepo ? `Gitea repo: ${p.giteaRepo}` : null,
|
||||||
p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null,
|
p.coolifyAppUuid ? `Coolify app UUID: ${p.coolifyAppUuid}` : null,
|
||||||
p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null,
|
p.deploymentUrl ? `Live URL: ${p.deploymentUrl}` : null,
|
||||||
p.theiaWorkspaceUrl ? `IDE: ${p.theiaWorkspaceUrl}` : null,
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
projectContext = lines.join("\n");
|
projectContext = lines.join("\n");
|
||||||
}
|
}
|
||||||
@@ -190,7 +188,7 @@ export async function DELETE(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/approve
|
||||||
|
*
|
||||||
|
* Called by the frontend when the user clicks "Approve & commit".
|
||||||
|
* Verifies ownership, then asks the agent runner to git commit + push
|
||||||
|
* the changes it made in the workspace, and triggers a Coolify deploy.
|
||||||
|
*
|
||||||
|
* Body: { commitMessage: string }
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
const COOLIFY_API_URL = process.env.COOLIFY_API_URL ?? "";
|
||||||
|
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? "";
|
||||||
|
|
||||||
|
interface AppEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
coolifyServiceUuid?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { commitMessage?: string };
|
||||||
|
const commitMessage = body.commitMessage?.trim();
|
||||||
|
if (!commitMessage) {
|
||||||
|
return NextResponse.json({ error: "commitMessage is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership + fetch project data (giteaRepo, apps list)
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectData = rows[0].data;
|
||||||
|
const giteaRepo = projectData?.giteaRepo as string | undefined;
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ error: "No Gitea repo linked to this project" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the session to get the appName (so we can find the right Coolify UUID)
|
||||||
|
const sessionRows = await query<{ app_name: string; status: string }>(
|
||||||
|
`SELECT app_name, status FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
|
||||||
|
[sessionId, projectId]
|
||||||
|
);
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (sessionRows[0].status !== "done") {
|
||||||
|
return NextResponse.json({ error: "Session must be in 'done' state to approve" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = sessionRows[0].app_name;
|
||||||
|
|
||||||
|
// Find the matching Coolify UUID from project.data.apps[]
|
||||||
|
const apps: AppEntry[] = (projectData?.apps ?? []) as AppEntry[];
|
||||||
|
const matchedApp = apps.find(a => a.name === appName);
|
||||||
|
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
|
||||||
|
|
||||||
|
// Call agent runner to commit + push
|
||||||
|
const approveRes = await fetch(`${AGENT_RUNNER_URL}/agent/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
giteaRepo,
|
||||||
|
commitMessage,
|
||||||
|
coolifyApiUrl: COOLIFY_API_URL,
|
||||||
|
coolifyApiToken: COOLIFY_API_TOKEN,
|
||||||
|
coolifyAppUuid,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveData = await approveRes.json() as {
|
||||||
|
ok: boolean;
|
||||||
|
committed?: boolean;
|
||||||
|
deployed?: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!approveRes.ok || !approveData.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: approveData.error ?? "Agent runner returned an error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as approved in DB
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'approved', completed_at = COALESCE(completed_at, now()), updated_at = now(),
|
||||||
|
output = output || $1::jsonb
|
||||||
|
WHERE id = $2::uuid`,
|
||||||
|
[
|
||||||
|
JSON.stringify([{
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
type: "done",
|
||||||
|
text: `✓ ${approveData.message ?? "Committed and pushed."}${approveData.deployed ? " Deployment triggered." : ""}`,
|
||||||
|
}]),
|
||||||
|
sessionId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
committed: approveData.committed,
|
||||||
|
deployed: approveData.deployed,
|
||||||
|
message: approveData.message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/approve]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to approve session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions/[sessionId]/events?afterSeq=0
|
||||||
|
* List persisted agent events for replay (user session auth).
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/events
|
||||||
|
* Batch append from vibn-agent-runner (x-agent-runner-secret).
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query, getPool } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export interface AgentSessionEventRow {
|
||||||
|
seq: number;
|
||||||
|
ts: string;
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
|
||||||
|
|
||||||
|
const rows = await query<AgentSessionEventRow>(
|
||||||
|
`SELECT e.seq, e.ts::text, e.type, e.payload
|
||||||
|
FROM agent_session_events e
|
||||||
|
JOIN agent_sessions s ON s.id = e.session_id
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE e.session_id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
AND e.seq > $4
|
||||||
|
ORDER BY e.seq ASC
|
||||||
|
LIMIT 2000`,
|
||||||
|
[sessionId, projectId, session.user.email, afterSeq]
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxSeq = rows.length ? rows[rows.length - 1].seq : afterSeq;
|
||||||
|
|
||||||
|
return NextResponse.json({ events: rows, maxSeq });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/.../events GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to list events" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type IngestBody = {
|
||||||
|
events: Array<{
|
||||||
|
clientEventId: string;
|
||||||
|
ts: string;
|
||||||
|
type: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
|
||||||
|
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
|
||||||
|
if (secret && incomingSecret !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
|
||||||
|
let body: IngestBody;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as IngestBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.events?.length) {
|
||||||
|
return NextResponse.json({ ok: true, inserted: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await client.query<{ n: string }>(
|
||||||
|
`SELECT 1 AS n FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`,
|
||||||
|
[sessionId, projectId]
|
||||||
|
);
|
||||||
|
if (exists.rowCount === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
await client.query("SELECT pg_advisory_xact_lock(hashtext($1::text))", [sessionId]);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
for (const ev of body.events) {
|
||||||
|
if (!ev.clientEventId || !ev.type || !ev.ts) continue;
|
||||||
|
|
||||||
|
const maxRes = await client.query<{ m: string }>(
|
||||||
|
`SELECT COALESCE(MAX(seq), 0)::text AS m FROM agent_session_events WHERE session_id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
const nextSeq = Number(maxRes.rows[0].m) + 1;
|
||||||
|
|
||||||
|
const ins = await client.query(
|
||||||
|
`INSERT INTO agent_session_events (session_id, project_id, seq, ts, type, payload, client_event_id)
|
||||||
|
VALUES ($1::uuid, $2, $3, $4::timestamptz, $5, $6::jsonb, $7::uuid)
|
||||||
|
ON CONFLICT (client_event_id) DO NOTHING`,
|
||||||
|
[
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
nextSeq,
|
||||||
|
ev.ts,
|
||||||
|
ev.type,
|
||||||
|
JSON.stringify(ev.payload ?? {}),
|
||||||
|
ev.clientEventId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (ins.rowCount) inserted += ins.rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return NextResponse.json({ ok: true, inserted });
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
console.error("[agent/sessions/.../events POST]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to ingest events" }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0
|
||||||
|
* Server-Sent Events: tail agent_session_events while the session is active.
|
||||||
|
*/
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query, queryOne } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
/** Long-lived SSE — raise if your host defaults to a shorter limit (e.g. Vercel). */
|
||||||
|
export const maxDuration = 300;
|
||||||
|
|
||||||
|
const TERMINAL = new Set(["done", "approved", "failed", "stopped"]);
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
let afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
|
||||||
|
|
||||||
|
const allowed = await queryOne<{ n: string }>(
|
||||||
|
`SELECT 1 AS n FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const signal = req.signal;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const send = (obj: object) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||||
|
};
|
||||||
|
|
||||||
|
let idleAfterTerminal = 0;
|
||||||
|
let lastHeartbeat = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const rows = await query<{ seq: number; ts: string; type: string; payload: Record<string, unknown> }>(
|
||||||
|
`SELECT e.seq, e.ts::text, e.type, e.payload
|
||||||
|
FROM agent_session_events e
|
||||||
|
WHERE e.session_id = $1::uuid AND e.seq > $2
|
||||||
|
ORDER BY e.seq ASC
|
||||||
|
LIMIT 200`,
|
||||||
|
[sessionId, afterSeq]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
afterSeq = row.seq;
|
||||||
|
send({ seq: row.seq, ts: row.ts, type: row.type, payload: row.payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const st = await queryOne<{ status: string }>(
|
||||||
|
`SELECT status FROM agent_sessions WHERE id = $1::uuid LIMIT 1`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
const status = st?.status ?? "";
|
||||||
|
const terminal = TERMINAL.has(status);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
if (terminal) {
|
||||||
|
idleAfterTerminal++;
|
||||||
|
if (idleAfterTerminal >= 3) {
|
||||||
|
send({ type: "_stream.end", seq: afterSeq });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idleAfterTerminal = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idleAfterTerminal = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastHeartbeat > 20000) {
|
||||||
|
send({ type: "_heartbeat", t: now });
|
||||||
|
lastHeartbeat = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 750));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[events/stream]", e);
|
||||||
|
try {
|
||||||
|
send({ type: "_stream.error", message: "stream failed" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream; charset=utf-8",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/retry
|
||||||
|
*
|
||||||
|
* Re-run a failed or stopped session, optionally with a follow-up instruction.
|
||||||
|
* Resets the session row to `running` and fires the agent-runner again.
|
||||||
|
*
|
||||||
|
* Body: { continueTask?: string }
|
||||||
|
* continueTask — if provided, appended to the original task so the agent
|
||||||
|
* understands what was already tried
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({})) as { continueTask?: string };
|
||||||
|
|
||||||
|
// Verify ownership and load the original session
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_path: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.project_id, s.app_name, s.app_path, s.task, s.status
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = rows[0];
|
||||||
|
|
||||||
|
if (!["failed", "stopped"].includes(s.status)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Session is ${s.status} — can only retry failed or stopped sessions` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch giteaRepo from the project
|
||||||
|
const proj = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id::text = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const giteaRepo = proj[0]?.data?.giteaRepo as string | undefined;
|
||||||
|
|
||||||
|
// Clear persisted event timeline so SSE / replay matches the new run (no-op if table missing)
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM agent_session_events WHERE session_id = $1::uuid`, [sessionId]);
|
||||||
|
} catch {
|
||||||
|
/* table may not exist until admin migrate */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the session row so the frontend shows it as running again
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'running',
|
||||||
|
error = NULL,
|
||||||
|
output = '[]'::jsonb,
|
||||||
|
changed_files = '[]'::jsonb,
|
||||||
|
started_at = now(),
|
||||||
|
completed_at = NULL,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-fire the agent runner
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
appName: s.app_name,
|
||||||
|
appPath: s.app_path,
|
||||||
|
giteaRepo,
|
||||||
|
task: s.task,
|
||||||
|
continueTask: body.continueTask?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn("[retry] runner not reachable:", err.message);
|
||||||
|
query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'failed', error = 'Agent runner not reachable', completed_at = now(), updated_at = now()
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessionId, status: "running" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[retry POST]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to retry session", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts
Normal file
121
app/api/projects/[projectId]/agent/sessions/[sessionId]/route.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions/[sessionId]
|
||||||
|
* Fetch a session's full state — status, output log, changed files.
|
||||||
|
* Frontend polls this (or will switch to WebSocket in Phase 3).
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions/[sessionId]/stop
|
||||||
|
* (handled in /stop/route.ts)
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{
|
||||||
|
id: string;
|
||||||
|
app_name: string;
|
||||||
|
app_path: string;
|
||||||
|
task: string;
|
||||||
|
plan: unknown;
|
||||||
|
status: string;
|
||||||
|
output: Array<{ ts: string; type: string; text: string }>;
|
||||||
|
changed_files: Array<{ path: string; status: string }>;
|
||||||
|
error: string | null;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.app_name, s.app_path, s.task, s.plan,
|
||||||
|
s.status, s.output, s.changed_files, s.error,
|
||||||
|
s.created_at, s.started_at, s.completed_at
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ session: rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/[id] GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Internal endpoint called by vibn-agent-runner to append output lines
|
||||||
|
* and update status. Requires x-agent-runner-secret header.
|
||||||
|
*/
|
||||||
|
const secret = process.env.AGENT_RUNNER_SECRET ?? "";
|
||||||
|
const incomingSecret = req.headers.get("x-agent-runner-secret") ?? "";
|
||||||
|
if (secret && incomingSecret !== secret) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const body = await req.json() as {
|
||||||
|
status?: string;
|
||||||
|
outputLine?: { ts: string; type: string; text: string };
|
||||||
|
changedFile?: { path: string; status: string };
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates: string[] = ["updated_at = now()"];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (body.status) {
|
||||||
|
updates.push(`status = $${idx++}`);
|
||||||
|
values.push(body.status);
|
||||||
|
if (["done", "approved", "failed", "stopped"].includes(body.status)) {
|
||||||
|
updates.push(`completed_at = now()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.error) {
|
||||||
|
updates.push(`error = $${idx++}`);
|
||||||
|
values.push(body.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.outputLine) {
|
||||||
|
updates.push(`output = output || $${idx++}::jsonb`);
|
||||||
|
values.push(JSON.stringify([body.outputLine]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.changedFile) {
|
||||||
|
updates.push(`changed_files = changed_files || $${idx++}::jsonb`);
|
||||||
|
values.push(JSON.stringify([body.changedFile]));
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(sessionId);
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions SET ${updates.join(", ")} WHERE id = $${idx}::uuid`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/[id] PATCH]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to update session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId, sessionId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ status: string }>(
|
||||||
|
`SELECT s.status FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3 LIMIT 1`,
|
||||||
|
[sessionId, projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Session not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows[0].status !== "running" && rows[0].status !== "pending") {
|
||||||
|
return NextResponse.json({ error: "Session is not running" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the agent runner to stop (best-effort)
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sessionId }),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Mark as stopped in DB immediately
|
||||||
|
await query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'stopped', completed_at = now(), updated_at = now(),
|
||||||
|
output = output || '[{"ts": "now", "type": "info", "text": "Stopped by user."}]'::jsonb
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions/stop]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to stop session" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/api/projects/[projectId]/agent/sessions/route.ts
Normal file
172
app/api/projects/[projectId]/agent/sessions/route.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Agent Sessions API
|
||||||
|
*
|
||||||
|
* POST /api/projects/[projectId]/agent/sessions
|
||||||
|
* Create a new agent session and kick it off via vibn-agent-runner.
|
||||||
|
* Body: { appName, appPath, task }
|
||||||
|
*
|
||||||
|
* GET /api/projects/[projectId]/agent/sessions
|
||||||
|
* List all sessions for a project, newest first.
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
// Verify the agent_sessions table is reachable. If it doesn't exist yet,
|
||||||
|
// throw a descriptive error instead of a generic "Failed to create session".
|
||||||
|
// Run POST /api/admin/migrate once to create the table.
|
||||||
|
async function ensureTable() {
|
||||||
|
await query(
|
||||||
|
`SELECT 1 FROM agent_sessions LIMIT 0`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST — create session ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { appName, appPath, task } = body as {
|
||||||
|
appName: string;
|
||||||
|
appPath: string;
|
||||||
|
task: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!appName || !appPath || !task?.trim()) {
|
||||||
|
return NextResponse.json({ error: "appName, appPath and task are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
// Verify ownership and fetch giteaRepo
|
||||||
|
const owns = await query<{ id: string; data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.id, p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (owns.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaRepo = owns[0].data?.giteaRepo as string | undefined;
|
||||||
|
|
||||||
|
// Find the Coolify UUID for this specific app so the runner can trigger a deploy
|
||||||
|
interface AppEntry { name: string; coolifyServiceUuid?: string | null; }
|
||||||
|
const apps = (owns[0].data?.apps ?? []) as AppEntry[];
|
||||||
|
const matchedApp = apps.find(a => a.name === appName);
|
||||||
|
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
|
||||||
|
|
||||||
|
// Create the session row
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at)
|
||||||
|
VALUES ($1::text, $2, $3, $4, 'running', now())
|
||||||
|
RETURNING id`,
|
||||||
|
[projectId, appName, appPath, task.trim()]
|
||||||
|
);
|
||||||
|
const sessionId = rows[0].id;
|
||||||
|
|
||||||
|
// Fire-and-forget: call agent-runner to start the execution loop.
|
||||||
|
// autoApprove: true — agent commits + deploys automatically on completion.
|
||||||
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
projectId,
|
||||||
|
appName,
|
||||||
|
appPath,
|
||||||
|
giteaRepo,
|
||||||
|
task: task.trim(),
|
||||||
|
autoApprove: true,
|
||||||
|
coolifyAppUuid,
|
||||||
|
}),
|
||||||
|
}).catch(err => {
|
||||||
|
// Agent runner may not be wired yet — log but don't fail
|
||||||
|
console.warn("[agent] runner not reachable:", err.message);
|
||||||
|
// Mark session as failed if runner unreachable
|
||||||
|
query(
|
||||||
|
`UPDATE agent_sessions
|
||||||
|
SET status = 'failed',
|
||||||
|
error = 'Agent runner not reachable',
|
||||||
|
completed_at = now(),
|
||||||
|
output = jsonb_build_array(jsonb_build_object(
|
||||||
|
'ts', now()::text,
|
||||||
|
'type', 'error',
|
||||||
|
'text', 'Agent runner service is not connected yet. Phase 2 implementation pending.'
|
||||||
|
))
|
||||||
|
WHERE id = $1::uuid`,
|
||||||
|
[sessionId]
|
||||||
|
).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessionId }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions POST]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create session", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET — list sessions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureTable();
|
||||||
|
|
||||||
|
const sessions = await query<{
|
||||||
|
id: string;
|
||||||
|
app_name: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
output: Array<{ ts: string; type: string; text: string }>;
|
||||||
|
changed_files: Array<{ path: string; status: string }>;
|
||||||
|
error: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT s.id, s.app_name, s.task, s.status,
|
||||||
|
s.created_at, s.started_at, s.completed_at,
|
||||||
|
s.output, s.changed_files, s.error
|
||||||
|
FROM agent_sessions s
|
||||||
|
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE s.project_id::text = $1 AND u.data->>'email' = $2
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
LIMIT 50`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ sessions });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[agent/sessions GET]", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
36
app/api/projects/[projectId]/analysis-status/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = rows[0].data ?? {};
|
||||||
|
const stage = (data.analysisStage as string) ?? 'cloning';
|
||||||
|
const analysisResult = stage === 'done' ? data.analysisResult : undefined;
|
||||||
|
|
||||||
|
return NextResponse.json({ stage, analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analysis-status]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
125
app/api/projects/[projectId]/analyze-chats/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 4096 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { chatText?: string };
|
||||||
|
const chatText = body.chatText?.trim() || '';
|
||||||
|
|
||||||
|
if (!chatText) {
|
||||||
|
return NextResponse.json({ error: 'chatText is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionPrompt = `You are a product analyst. A founder has pasted AI chat conversation history below.
|
||||||
|
|
||||||
|
Extract and categorise the following from those conversations. Return ONLY valid JSON — no markdown, no explanation.
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"decisions": ["string — concrete decisions already made"],
|
||||||
|
"ideas": ["string — product ideas and features mentioned"],
|
||||||
|
"openQuestions": ["string — unresolved questions that still need answers"],
|
||||||
|
"architecture": ["string — technical architecture notes, stack choices, infra decisions"],
|
||||||
|
"targetUsers": ["string — user segments, personas, or target audiences mentioned"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Each array can be empty if nothing was found for that category. Extract real content — be specific and concise. Max 10 items per bucket.
|
||||||
|
|
||||||
|
--- CHAT HISTORY START ---
|
||||||
|
${chatText.slice(0, 12000)}
|
||||||
|
--- CHAT HISTORY END ---
|
||||||
|
|
||||||
|
Return only the JSON object:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(extractionPrompt);
|
||||||
|
|
||||||
|
let analysisResult: {
|
||||||
|
decisions: string[];
|
||||||
|
ideas: string[];
|
||||||
|
openQuestions: string[];
|
||||||
|
architecture: string[];
|
||||||
|
targetUsers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw) as typeof analysisResult;
|
||||||
|
} catch {
|
||||||
|
// Fallback: return empty buckets with a note
|
||||||
|
analysisResult = {
|
||||||
|
decisions: [],
|
||||||
|
ideas: [],
|
||||||
|
openQuestions: ["Could not parse extracted insights — try pasting more structured conversation"],
|
||||||
|
architecture: [],
|
||||||
|
targetUsers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save analysis result to project data
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'review',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ analysisResult });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-chats]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
215
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
215
app/api/projects/[projectId]/analyze-repo/route.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.2, maxOutputTokens: 6000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonBlock(raw: string): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const cleaned = trimmed.startsWith('```')
|
||||||
|
? trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||||
|
: trimmed;
|
||||||
|
return JSON.parse(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a file safely, returning empty string on failure
|
||||||
|
function safeRead(path: string, maxBytes = 8000): string {
|
||||||
|
try {
|
||||||
|
if (!existsSync(path)) return '';
|
||||||
|
const content = readFileSync(path, 'utf8');
|
||||||
|
return content.slice(0, maxBytes);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk directory and collect file listing (relative paths), limited to avoid huge outputs
|
||||||
|
function walkDir(dir: string, depth = 0, maxDepth = 4, acc: string[] = []): string[] {
|
||||||
|
if (depth > maxDepth) return acc;
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__' || e.name === '.git') continue;
|
||||||
|
const full = join(dir, e.name);
|
||||||
|
const rel = full.replace(dir + '/', '');
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
acc.push(rel + '/');
|
||||||
|
walkDir(full, depth + 1, maxDepth, acc);
|
||||||
|
} else {
|
||||||
|
acc.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStage(projectId: string, currentData: Record<string, unknown>, stage: string) {
|
||||||
|
const updated = { ...currentData, analysisStage: stage, updatedAt: new Date().toISOString() };
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as { repoUrl?: string };
|
||||||
|
const repoUrl = body.repoUrl?.trim() || '';
|
||||||
|
|
||||||
|
if (!repoUrl.startsWith('http')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid repository URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentData = rows[0].data ?? {};
|
||||||
|
currentData = await updateStage(projectId, currentData, 'cloning');
|
||||||
|
|
||||||
|
// Clone repo into temp dir (fire and forget — status is polled separately)
|
||||||
|
const tmpDir = `/tmp/vibn-${projectId}`;
|
||||||
|
|
||||||
|
// Run async so the request returns quickly and client can poll
|
||||||
|
setImmediate(async () => {
|
||||||
|
try {
|
||||||
|
// Clean up any existing clone
|
||||||
|
if (existsSync(tmpDir)) {
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(`git clone --depth=1 "${repoUrl}" "${tmpDir}"`, {
|
||||||
|
timeout: 60_000,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = { ...currentData };
|
||||||
|
data = await updateStage(projectId, data, 'reading');
|
||||||
|
|
||||||
|
// Read key files
|
||||||
|
const manifest: Record<string, string> = {};
|
||||||
|
const keyFiles = [
|
||||||
|
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
||||||
|
'requirements.txt', 'Pipfile', 'pyproject.toml',
|
||||||
|
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
|
||||||
|
'README.md', '.env.example', '.env.sample',
|
||||||
|
'next.config.js', 'next.config.ts', 'next.config.mjs',
|
||||||
|
'vite.config.ts', 'vite.config.js',
|
||||||
|
'tsconfig.json',
|
||||||
|
'prisma/schema.prisma', 'schema.prisma',
|
||||||
|
];
|
||||||
|
for (const f of keyFiles) {
|
||||||
|
const content = safeRead(join(tmpDir, f));
|
||||||
|
if (content) manifest[f] = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileListing = walkDir(tmpDir).slice(0, 300).join('\n');
|
||||||
|
|
||||||
|
data = await updateStage(projectId, data, 'analyzing');
|
||||||
|
|
||||||
|
const analysisPrompt = `You are a senior full-stack architect. Analyse this repository and return a structured architecture map.
|
||||||
|
|
||||||
|
File listing (top-level):
|
||||||
|
${fileListing}
|
||||||
|
|
||||||
|
Key file contents:
|
||||||
|
${Object.entries(manifest).map(([k, v]) => `\n### ${k}\n${v}`).join('')}
|
||||||
|
|
||||||
|
Return ONLY valid JSON with this structure:
|
||||||
|
{
|
||||||
|
"summary": "1-2 sentence project summary",
|
||||||
|
"rows": [
|
||||||
|
{ "category": "Tech Stack", "item": "Next.js 15", "status": "found", "detail": "next.config.ts present" },
|
||||||
|
{ "category": "Database", "item": "PostgreSQL", "status": "found", "detail": "prisma/schema.prisma detected" },
|
||||||
|
{ "category": "Auth", "item": "Authentication", "status": "missing", "detail": "No auth library detected" }
|
||||||
|
],
|
||||||
|
"suggestedSurfaces": ["marketing", "admin"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Categories to cover: Tech Stack, Infrastructure, Database, API Surface, Frontend, Auth, Third-party, Missing / Gaps
|
||||||
|
Status values: "found", "partial", "missing"
|
||||||
|
suggestedSurfaces should only include items from: ["marketing", "web-app", "admin", "api"]
|
||||||
|
Suggest surfaces that are MISSING or incomplete in the current codebase.
|
||||||
|
|
||||||
|
Return only the JSON:`;
|
||||||
|
|
||||||
|
const raw = await callGemini(analysisPrompt);
|
||||||
|
let analysisResult;
|
||||||
|
try {
|
||||||
|
analysisResult = parseJsonBlock(raw);
|
||||||
|
} catch {
|
||||||
|
analysisResult = {
|
||||||
|
summary: 'Could not fully parse the repository structure.',
|
||||||
|
rows: [{ category: 'Tech Stack', item: 'Repository detected', status: 'found', detail: fileListing.split('\n').slice(0, 5).join(', ') }],
|
||||||
|
suggestedSurfaces: ['marketing'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save result and mark done
|
||||||
|
const finalData = {
|
||||||
|
...data,
|
||||||
|
analysisStage: 'done',
|
||||||
|
analysisResult,
|
||||||
|
creationStage: 'mapping',
|
||||||
|
sourceData: { ...(data.sourceData as object || {}), repoUrl },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(finalData)]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo] background error', err);
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify({ ...currentData, analysisStage: 'error', analysisError: String(err) })]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ started: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[analyze-repo]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/api/projects/[projectId]/analyze/route.ts
Normal file
120
app/api/projects/[projectId]/analyze/route.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
|
||||||
|
// GET — check the current analysis status for a project
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const project = rows[0].data;
|
||||||
|
|
||||||
|
if (!project.isImport) {
|
||||||
|
return NextResponse.json({ isImport: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = project.importAnalysisJobId;
|
||||||
|
let jobStatus: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
// Fetch live job status from agent runner if we have a job ID
|
||||||
|
if (jobId) {
|
||||||
|
try {
|
||||||
|
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/jobs/${jobId}`);
|
||||||
|
if (jobRes.ok) {
|
||||||
|
jobStatus = await jobRes.json() as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Sync terminal status back to the project record
|
||||||
|
const runnerStatus = jobStatus.status as string | undefined;
|
||||||
|
if (runnerStatus && runnerStatus !== project.importAnalysisStatus) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(data, '{importAnalysisStatus}', $1::jsonb) WHERE id = $2`,
|
||||||
|
[JSON.stringify(runnerStatus), projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Agent runner unreachable — return last known status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
isImport: true,
|
||||||
|
status: project.importAnalysisStatus ?? 'pending',
|
||||||
|
jobId,
|
||||||
|
job: jobStatus,
|
||||||
|
githubRepoUrl: project.githubRepoUrl,
|
||||||
|
giteaRepo: project.giteaRepo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST — (re-)trigger an analysis job for a project
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const rows = await query<{ data: any }>(
|
||||||
|
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
if (!rows.length) return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const project = rows[0].data;
|
||||||
|
|
||||||
|
if (!project.giteaRepo) {
|
||||||
|
return NextResponse.json({ error: 'Project has no Gitea repo' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jobRes = await fetch(`${AGENT_RUNNER_URL}/api/agent/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent: 'ImportAnalyzer',
|
||||||
|
task: `Analyze this codebase${project.githubRepoUrl ? ` (originally from ${project.githubRepoUrl})` : ''} and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||||
|
repo: project.giteaRepo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!jobRes.ok) {
|
||||||
|
const detail = await jobRes.text();
|
||||||
|
return NextResponse.json({ error: 'Failed to start analysis', details: detail }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobData = await jobRes.json() as { jobId?: string };
|
||||||
|
const jobId = jobData.jobId ?? null;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||||
|
[JSON.stringify(jobId), projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, jobId, status: 'running' });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to start analysis', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
@@ -25,7 +24,7 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -48,6 +47,7 @@ export async function GET(
|
|||||||
let apps: { name: string; path: string }[] = [];
|
let apps: { name: string; path: string }[] = [];
|
||||||
|
|
||||||
if (giteaRepo) {
|
if (giteaRepo) {
|
||||||
|
// First: try the standard turborepo apps/ directory
|
||||||
try {
|
try {
|
||||||
const contents: Array<{ name: string; path: string; type: string }> =
|
const contents: Array<{ name: string; path: string; type: string }> =
|
||||||
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
|
await giteaGet(`/repos/${giteaRepo}/contents/apps`);
|
||||||
@@ -55,11 +55,64 @@ export async function GET(
|
|||||||
.filter((item) => item.type === 'dir')
|
.filter((item) => item.type === 'dir')
|
||||||
.map(({ name, path }) => ({ name, path }));
|
.map(({ name, path }) => ({ name, path }));
|
||||||
} catch {
|
} catch {
|
||||||
// Repo may not have an apps/ dir yet — return empty list gracefully
|
// No apps/ dir — fall through to import detection below
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: no apps/ dir — scan repo root for deployable components.
|
||||||
|
// Works for any project structure (imported, single-repo, monorepo variants).
|
||||||
|
if (apps.length === 0) {
|
||||||
|
try {
|
||||||
|
// Try CODEBASE_MAP.md first (written by ImportAnalyzer for imported repos)
|
||||||
|
const mapFile = await giteaGet(`/repos/${giteaRepo}/contents/CODEBASE_MAP.md`).catch(() => null);
|
||||||
|
if (mapFile?.content) {
|
||||||
|
const decoded = Buffer.from(mapFile.content, 'base64').toString('utf-8');
|
||||||
|
const matches = [...decoded.matchAll(/###\s+.+?[—–-]\s+[`]?([^`\n(]+)[`]?/g)];
|
||||||
|
const parsedApps = matches
|
||||||
|
.map(m => m[1].trim().replace(/^`|`$/g, '').replace(/\/$/, ''))
|
||||||
|
.filter(p => p && p.length > 0 && !p.includes(' ') && !p.startsWith('http') && p !== '.')
|
||||||
|
.map(p => ({ name: p.split('/').pop() ?? p, path: p }));
|
||||||
|
if (parsedApps.length > 0) apps = parsedApps;
|
||||||
|
}
|
||||||
|
} catch { /* CODEBASE_MAP not available */ }
|
||||||
|
|
||||||
|
// Scan top-level dirs for app signals
|
||||||
|
if (apps.length === 0) {
|
||||||
|
try {
|
||||||
|
const SKIP = new Set(['docs', 'scripts', 'keys', '.github', 'node_modules', '.git', 'dist', 'build', 'coverage']);
|
||||||
|
const APP_SIGNALS = ['package.json', 'requirements.txt', 'pyproject.toml', 'Dockerfile', 'next.config.ts', 'next.config.js', 'vite.config.ts', 'main.py', 'app.py', 'index.js', 'server.ts'];
|
||||||
|
|
||||||
|
const root: Array<{ name: string; path: string; type: string }> =
|
||||||
|
await giteaGet(`/repos/${giteaRepo}/contents/`);
|
||||||
|
|
||||||
|
// Check if the root itself is an app (single-repo projects)
|
||||||
|
const rootIsApp = root.some(f => f.type === 'file' && APP_SIGNALS.includes(f.name));
|
||||||
|
if (rootIsApp) {
|
||||||
|
// Repo root is the app — use repo name as label, empty string as path
|
||||||
|
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
|
||||||
|
} else {
|
||||||
|
// Scan subdirs
|
||||||
|
const dirs = root.filter(i => i.type === 'dir' && !SKIP.has(i.name));
|
||||||
|
const candidates = await Promise.all(
|
||||||
|
dirs.map(async (dir) => {
|
||||||
|
try {
|
||||||
|
const sub: Array<{ name: string; type: string }> = await giteaGet(`/repos/${giteaRepo}/contents/${dir.path}`);
|
||||||
|
return sub.some(f => APP_SIGNALS.includes(f.name)) ? { name: dir.name, path: dir.path } : null;
|
||||||
|
} catch { return null; }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
apps = candidates.filter((a): a is { name: string; path: string } => a !== null);
|
||||||
|
}
|
||||||
|
} catch { /* scan failed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: expose the repo root so the file tree still works
|
||||||
|
if (apps.length === 0) {
|
||||||
|
apps = [{ name: giteaRepo.split('/').pop() ?? 'app', path: '' }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ apps, designPackages, giteaRepo });
|
return NextResponse.json({ apps, designPackages, giteaRepo, isImport: !!(data.isImport || data.creationMode === 'migration') });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +124,7 @@ export async function PATCH(
|
|||||||
) {
|
) {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from "@/lib/auth/authOptions";
|
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
@@ -13,7 +12,7 @@ export async function GET(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -43,7 +42,7 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -184,7 +183,7 @@ export async function PATCH(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,47 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from "@/lib/auth/authOptions";
|
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
import {
|
||||||
|
augmentAtlasMessage,
|
||||||
|
parseContextRefs,
|
||||||
|
} from "@/lib/chat-context-refs";
|
||||||
|
|
||||||
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333";
|
||||||
|
|
||||||
|
const ALLOWED_SCOPES = new Set(["overview", "build"]);
|
||||||
|
|
||||||
|
function normalizeScope(raw: string | null | undefined): "overview" | "build" {
|
||||||
|
const s = (raw ?? "overview").trim();
|
||||||
|
return ALLOWED_SCOPES.has(s) ? (s as "overview" | "build") : "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function runnerSessionId(projectId: string, scope: "overview" | "build"): string {
|
||||||
|
return scope === "overview" ? `atlas_${projectId}` : `atlas_${projectId}__build`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// DB helpers — atlas_conversations table
|
// DB — atlas_chat_threads (project_id + scope); legacy atlas_conversations → overview
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let tableReady = false;
|
let threadsTableReady = false;
|
||||||
|
let legacyTableChecked = false;
|
||||||
|
|
||||||
async function ensureTable() {
|
async function ensureThreadsTable() {
|
||||||
if (tableReady) return;
|
if (threadsTableReady) return;
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS atlas_chat_threads (
|
||||||
|
project_id TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
messages JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (project_id, scope)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
threadsTableReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLegacyConversationsTable() {
|
||||||
|
if (legacyTableChecked) return;
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS atlas_conversations (
|
CREATE TABLE IF NOT EXISTS atlas_conversations (
|
||||||
project_id TEXT PRIMARY KEY,
|
project_id TEXT PRIMARY KEY,
|
||||||
@@ -20,31 +49,47 @@ async function ensureTable() {
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
tableReady = true;
|
legacyTableChecked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAtlasHistory(projectId: string): Promise<any[]> {
|
async function loadAtlasHistory(projectId: string, scope: "overview" | "build"): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
await ensureTable();
|
await ensureThreadsTable();
|
||||||
const rows = await query<{ messages: any[] }>(
|
const rows = await query<{ messages: any[] }>(
|
||||||
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
|
`SELECT messages FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
|
||||||
[projectId]
|
[projectId, scope]
|
||||||
);
|
);
|
||||||
return rows[0]?.messages ?? [];
|
if (rows.length > 0) {
|
||||||
|
const fromThreads = rows[0]?.messages;
|
||||||
|
return Array.isArray(fromThreads) ? fromThreads : [];
|
||||||
|
}
|
||||||
|
if (scope === "overview") {
|
||||||
|
await ensureLegacyConversationsTable();
|
||||||
|
const leg = await query<{ messages: any[] }>(
|
||||||
|
`SELECT messages FROM atlas_conversations WHERE project_id = $1`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const legacyMsgs = leg[0]?.messages ?? [];
|
||||||
|
if (Array.isArray(legacyMsgs) && legacyMsgs.length > 0) {
|
||||||
|
await saveAtlasHistory(projectId, scope, legacyMsgs);
|
||||||
|
return legacyMsgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAtlasHistory(projectId: string, messages: any[]): Promise<void> {
|
async function saveAtlasHistory(projectId: string, scope: "overview" | "build", messages: any[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureTable();
|
await ensureThreadsTable();
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO atlas_conversations (project_id, messages, updated_at)
|
`INSERT INTO atlas_chat_threads (project_id, scope, messages, updated_at)
|
||||||
VALUES ($1, $2::jsonb, NOW())
|
VALUES ($1, $2, $3::jsonb, NOW())
|
||||||
ON CONFLICT (project_id) DO UPDATE
|
ON CONFLICT (project_id, scope) DO UPDATE
|
||||||
SET messages = $2::jsonb, updated_at = NOW()`,
|
SET messages = $3::jsonb, updated_at = NOW()`,
|
||||||
[projectId, JSON.stringify(messages)]
|
[projectId, scope, JSON.stringify(messages)]
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[atlas-chat] Failed to save history:", e);
|
console.error("[atlas-chat] Failed to save history:", e);
|
||||||
@@ -66,21 +111,36 @@ async function savePrd(projectId: string, prdContent: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the latest user message content so DB/UI never show the internal ref prefix. */
|
||||||
|
function scrubLastUserMessageContent(history: unknown[], cleanText: string): unknown[] {
|
||||||
|
if (!Array.isArray(history) || history.length === 0) return history;
|
||||||
|
const h = history.map(m => (m && typeof m === "object" ? { ...(m as object) } : m));
|
||||||
|
for (let i = h.length - 1; i >= 0; i--) {
|
||||||
|
const m = h[i] as { role?: string; content?: string };
|
||||||
|
if (m?.role === "user" && typeof m.content === "string") {
|
||||||
|
h[i] = { ...m, content: cleanText };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET — load stored conversation messages for display
|
// GET — load stored conversation messages for display
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const history = await loadAtlasHistory(projectId);
|
const scope = normalizeScope(req.nextUrl.searchParams.get("scope"));
|
||||||
|
const history = await loadAtlasHistory(projectId, scope);
|
||||||
|
|
||||||
// Filter to only user/assistant messages (no system prompts) for display
|
// Filter to only user/assistant messages (no system prompts) for display
|
||||||
const messages = history
|
const messages = history
|
||||||
@@ -98,43 +158,50 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const { message } = await req.json();
|
const body = await req.json();
|
||||||
|
const message = body?.message as string | undefined;
|
||||||
|
const contextRefs = parseContextRefs(body?.contextRefs);
|
||||||
if (!message?.trim()) {
|
if (!message?.trim()) {
|
||||||
return NextResponse.json({ error: "message is required" }, { status: 400 });
|
return NextResponse.json({ error: "message is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = `atlas_${projectId}`;
|
const scope = normalizeScope(body?.scope as string | undefined);
|
||||||
|
const sessionId = runnerSessionId(projectId, scope);
|
||||||
|
const cleanUserText = message.trim();
|
||||||
|
|
||||||
// Load conversation history from DB to persist across agent runner restarts.
|
// Load conversation history from DB to persist across agent runner restarts.
|
||||||
// Strip tool_call / tool_response messages — replaying them across sessions
|
// Strip tool_call / tool_response messages — replaying them across sessions
|
||||||
// causes Gemini to reject the request with a turn-ordering error.
|
// causes Gemini to reject the request with a turn-ordering error.
|
||||||
const rawHistory = await loadAtlasHistory(projectId);
|
const rawHistory = await loadAtlasHistory(projectId, scope);
|
||||||
const history = rawHistory.filter((m: any) =>
|
const history = rawHistory.filter((m: any) =>
|
||||||
(m.role === "user" || m.role === "assistant") && m.content
|
(m.role === "user" || m.role === "assistant") && m.content
|
||||||
);
|
);
|
||||||
|
|
||||||
// __init__ is a special internal trigger used only when there is no existing history.
|
// __init__ is a special internal trigger used only when there is no existing history.
|
||||||
// If history already exists, ignore the init request (conversation already started).
|
// If history already exists, ignore the init request (conversation already started).
|
||||||
const isInit = message.trim() === "__atlas_init__";
|
const isInit = cleanUserText === "__atlas_init__";
|
||||||
if (isInit && history.length > 0) {
|
if (isInit && history.length > 0) {
|
||||||
return NextResponse.json({ reply: null, alreadyStarted: true });
|
return NextResponse.json({ reply: null, alreadyStarted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runnerMessage = isInit
|
||||||
|
? scope === "build"
|
||||||
|
? "Begin as Vibn in build mode. The user is working in their monorepo. Ask what they want to ship or fix next, and offer concrete implementation guidance. Do not acknowledge this as an internal trigger."
|
||||||
|
: "Begin the conversation. Introduce yourself as Vibn and ask what the user is building. Do not acknowledge this as an internal trigger."
|
||||||
|
: augmentAtlasMessage(cleanUserText, contextRefs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
|
const res = await fetch(`${AGENT_RUNNER_URL}/atlas/chat`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
// For init, send the greeting prompt but don't store it as a user message
|
message: runnerMessage,
|
||||||
message: isInit
|
|
||||||
? "Begin the conversation. Introduce yourself as Atlas and ask what the user is building. Do not acknowledge this as an internal trigger."
|
|
||||||
: message,
|
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
history,
|
history,
|
||||||
is_init: isInit,
|
is_init: isInit,
|
||||||
@@ -146,18 +213,23 @@ export async function POST(
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
console.error("[atlas-chat] Agent runner error:", text);
|
console.error("[atlas-chat] Agent runner error:", text);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Atlas is unavailable. Please try again." },
|
{ error: "Vibn is unavailable. Please try again." },
|
||||||
{ status: 502 }
|
{ status: 502 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Persist updated history
|
let historyOut = data.history ?? [];
|
||||||
await saveAtlasHistory(projectId, data.history ?? []);
|
// Store the user's line without the internal reference block (UI shows clean text).
|
||||||
|
if (!isInit && cleanUserText !== "__atlas_init__") {
|
||||||
|
historyOut = scrubLastUserMessageContent(historyOut, cleanUserText);
|
||||||
|
}
|
||||||
|
|
||||||
// If Atlas finalized the PRD, save it to the project
|
await saveAtlasHistory(projectId, scope, historyOut);
|
||||||
if (data.prdContent) {
|
|
||||||
|
// If Atlas finalized the PRD, save it to the project (discovery / overview)
|
||||||
|
if (data.prdContent && scope === "overview") {
|
||||||
await savePrd(projectId, data.prdContent);
|
await savePrd(projectId, data.prdContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,24 +253,35 @@ export async function POST(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const sessionId = `atlas_${projectId}`;
|
const scope = normalizeScope(req.nextUrl.searchParams.get("scope"));
|
||||||
|
const sessionId = runnerSessionId(projectId, scope);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${sessionId}`, { method: "DELETE" });
|
await fetch(`${AGENT_RUNNER_URL}/atlas/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
|
||||||
} catch { /* runner may be down */ }
|
} catch { /* runner may be down */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
|
await ensureThreadsTable();
|
||||||
|
await query(
|
||||||
|
`DELETE FROM atlas_chat_threads WHERE project_id = $1 AND scope = $2`,
|
||||||
|
[projectId, scope]
|
||||||
|
);
|
||||||
} catch { /* table may not exist yet */ }
|
} catch { /* table may not exist yet */ }
|
||||||
|
|
||||||
|
if (scope === "overview") {
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM atlas_conversations WHERE project_id = $1`, [projectId]);
|
||||||
|
} catch { /* legacy */ }
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ cleared: true });
|
return NextResponse.json({ cleared: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +11,7 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
const rows = await query<{ data: Record<string, unknown> }>(
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
@@ -49,7 +48,7 @@ export async function PATCH(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
// Step 1: read current data — explicit ::text casts on every param
|
// Step 1: read current data — explicit ::text casts on every param
|
||||||
|
|||||||
107
app/api/projects/[projectId]/file/route.ts
Normal file
107
app/api/projects/[projectId]/file/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/projects/[projectId]/file?path=apps/admin
|
||||||
|
*
|
||||||
|
* Returns directory listing or file content from the project's Gitea repo.
|
||||||
|
* Response for directory: { type: "dir", items: [{ name, path, type }] }
|
||||||
|
* Response for file: { type: "file", content: string, encoding: "utf8" | "base64" }
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
|
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
|
||||||
|
|
||||||
|
async function giteaGet(path: string) {
|
||||||
|
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
|
||||||
|
headers: { Authorization: `token ${GITEA_API_TOKEN}` },
|
||||||
|
next: { revalidate: 10 },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Gitea ${res.status}: ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico',
|
||||||
|
'woff', 'woff2', 'ttf', 'eot',
|
||||||
|
'zip', 'tar', 'gz', 'pdf',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isBinary(name: string): boolean {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
return BINARY_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const filePath = searchParams.get('path') ?? '';
|
||||||
|
|
||||||
|
// Verify ownership + get giteaRepo
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaRepo = rows[0].data?.giteaRepo as string | undefined;
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ error: 'No Gitea repo connected' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPath = filePath ? encodeURIComponent(filePath).replace(/%2F/g, '/') : '';
|
||||||
|
const apiPath = `/repos/${giteaRepo}/contents/${encodedPath}`;
|
||||||
|
const data = await giteaGet(apiPath);
|
||||||
|
|
||||||
|
// Directory listing
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const items = data
|
||||||
|
.map((item: { name: string; path: string; type: string; size?: number }) => ({
|
||||||
|
name: item.name,
|
||||||
|
path: item.path,
|
||||||
|
type: item.type, // "file" | "dir" | "symlink"
|
||||||
|
size: item.size,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Dirs first
|
||||||
|
if (a.type === 'dir' && b.type !== 'dir') return -1;
|
||||||
|
if (a.type !== 'dir' && b.type === 'dir') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return NextResponse.json({ type: 'dir', items });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single file
|
||||||
|
const item = data as { name: string; content?: string; encoding?: string; size?: number };
|
||||||
|
if (isBinary(item.name)) {
|
||||||
|
return NextResponse.json({ type: 'file', content: '(binary file)', encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea returns base64-encoded content
|
||||||
|
const raw = item.content ?? '';
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = Buffer.from(raw.replace(/\n/g, ''), 'base64').toString('utf8');
|
||||||
|
} catch {
|
||||||
|
content = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ type: 'file', content, encoding: 'utf8', name: item.name });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[file API]', err);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
138
app/api/projects/[projectId]/generate-migration-plan/route.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||||
|
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||||
|
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
|
||||||
|
async function callGemini(prompt: string): Promise<string> {
|
||||||
|
const res = await fetch(`${GEMINI_BASE_URL}/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { temperature: 0.3, maxOutputTokens: 8000 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { projectId } = await params;
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json() as {
|
||||||
|
analysisResult?: Record<string, unknown>;
|
||||||
|
sourceData?: { repoUrl?: string; liveUrl?: string; hosting?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1::text AND u.data->>'email' = $2::text LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = rows[0].data ?? {};
|
||||||
|
const projectName = (current.productName as string) || (current.name as string) || 'the product';
|
||||||
|
const { analysisResult, sourceData } = body;
|
||||||
|
|
||||||
|
const prompt = `You are a senior DevOps and platform migration architect. Generate a comprehensive, phased migration plan in Markdown for migrating an existing product into a new infrastructure (VIBN — a self-hosted PaaS).
|
||||||
|
|
||||||
|
Product: ${projectName}
|
||||||
|
Repo: ${sourceData?.repoUrl || 'Not provided'}
|
||||||
|
Live URL: ${sourceData?.liveUrl || 'Not provided'}
|
||||||
|
Current hosting: ${sourceData?.hosting || 'Unknown'}
|
||||||
|
|
||||||
|
Architecture audit summary:
|
||||||
|
${analysisResult?.summary || 'No audit data provided.'}
|
||||||
|
|
||||||
|
Detected components:
|
||||||
|
${JSON.stringify(analysisResult?.rows || [], null, 2).slice(0, 3000)}
|
||||||
|
|
||||||
|
Generate a complete migration plan with exactly these 4 phases:
|
||||||
|
|
||||||
|
# ${projectName} — Migration Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Brief 2-3 sentence description of the migration approach and guiding principle (non-destructive duplication).
|
||||||
|
|
||||||
|
## Phase 1: Mirror
|
||||||
|
Set up parallel infrastructure on VIBN without touching production.
|
||||||
|
- [ ] Clone repository to VIBN Gitea
|
||||||
|
- [ ] Configure Coolify application
|
||||||
|
- [ ] Set up identical database schema
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Verify build passes
|
||||||
|
|
||||||
|
## Phase 2: Validate
|
||||||
|
Run both systems in parallel and compare outputs.
|
||||||
|
- [ ] Route 5% of traffic to new infrastructure (or test internally)
|
||||||
|
- [ ] Compare API responses between old and new
|
||||||
|
- [ ] Run full end-to-end test suite
|
||||||
|
- [ ] Validate data sync between databases
|
||||||
|
- [ ] Sign off on performance benchmarks
|
||||||
|
|
||||||
|
## Phase 3: Cutover
|
||||||
|
Redirect production traffic to the new infrastructure.
|
||||||
|
- [ ] Update DNS records to point to VIBN load balancer
|
||||||
|
- [ ] Monitor error rates and latency for 24h
|
||||||
|
- [ ] Validate all integrations (auth, payments, third-party APIs)
|
||||||
|
- [ ] Keep old infrastructure on standby for 7 days
|
||||||
|
|
||||||
|
## Phase 4: Decommission
|
||||||
|
Remove old infrastructure after successful validation period.
|
||||||
|
- [ ] Confirm all data has been migrated
|
||||||
|
- [ ] Archive old repository access
|
||||||
|
- [ ] Terminate old hosting resources
|
||||||
|
- [ ] Update all internal documentation
|
||||||
|
|
||||||
|
## Risk Register
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Database migration failure | Medium | High | Full backup before any migration step |
|
||||||
|
| DNS propagation delay | Low | Medium | Use low TTL before cutover |
|
||||||
|
| Third-party integration breakage | Medium | High | Test all webhooks and OAuth in Phase 2 |
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
At any phase, revert by: pointing DNS back to original infrastructure. Data written during parallel run must be synced back manually. Old infrastructure MUST remain live until Phase 4 completes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Write a thorough, specific plan. Use real details from the audit where available. Every checklist item should be actionable. Return only the Markdown document.`;
|
||||||
|
|
||||||
|
const migrationPlan = await callGemini(prompt);
|
||||||
|
|
||||||
|
// Save to project
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
migrationPlan,
|
||||||
|
creationStage: 'plan',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = $2::jsonb WHERE id = $1::text`,
|
||||||
|
[projectId, JSON.stringify(updated)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ migrationPlan });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[generate-migration-plan]', err);
|
||||||
|
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { createKnowledgeItem } from '@/lib/server/knowledge';
|
import { createKnowledgeItem } from '@/lib/server/knowledge';
|
||||||
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
|
import type { KnowledgeSourceMeta } from '@/lib/types/knowledge';
|
||||||
@@ -34,7 +33,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
|
return NextResponse.json({ error: 'transcript is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -10,7 +9,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from "@/lib/auth/authOptions";
|
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
|
async function assertOwnership(projectId: string, email: string): Promise<boolean> {
|
||||||
@@ -18,7 +17,7 @@ export async function GET(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
@@ -41,7 +40,7 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
@@ -83,7 +82,7 @@ export async function DELETE(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session?.user?.email) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|||||||
94
app/api/projects/[projectId]/preview-url/route.ts
Normal file
94
app/api/projects/[projectId]/preview-url/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { listApplications, CoolifyApplication } from '@/lib/coolify';
|
||||||
|
|
||||||
|
const GITEA_BASE = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||||
|
|
||||||
|
export interface PreviewApp {
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
status: string;
|
||||||
|
coolifyUuid: string | null;
|
||||||
|
gitRepo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
|
) {
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Load project — get the Gitea repo name
|
||||||
|
const rows = await query<{ data: Record<string, unknown> }>(
|
||||||
|
`SELECT p.data FROM fs_projects p
|
||||||
|
JOIN fs_users u ON u.id = p.user_id
|
||||||
|
WHERE p.id = $1 AND u.data->>'email' = $2 LIMIT 1`,
|
||||||
|
[projectId, session.user.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = rows[0].data ?? {};
|
||||||
|
const giteaRepo = data.giteaRepo as string | undefined; // e.g. "mark/scout-ai-engine"
|
||||||
|
|
||||||
|
if (!giteaRepo) {
|
||||||
|
return NextResponse.json({ apps: [], message: 'No Gitea repo linked to this project' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build the possible Gitea remote URLs for this repo (with and without .git)
|
||||||
|
const repoBase = `${GITEA_BASE}/${giteaRepo}`;
|
||||||
|
const repoUrls = new Set([repoBase, `${repoBase}.git`]);
|
||||||
|
|
||||||
|
// 3. Fetch all Coolify applications and match by git_repository
|
||||||
|
let coolifyApps: CoolifyApplication[] = [];
|
||||||
|
try {
|
||||||
|
coolifyApps = await listApplications();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[preview-url] Coolify fetch failed:', err);
|
||||||
|
// Fall back to stored data
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = coolifyApps.filter(app =>
|
||||||
|
app.git_repository && repoUrls.has(app.git_repository.replace(/\/$/, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matched.length > 0) {
|
||||||
|
const apps: PreviewApp[] = matched.map(app => ({
|
||||||
|
name: app.name,
|
||||||
|
url: app.fqdn
|
||||||
|
? (app.fqdn.startsWith('http') ? app.fqdn : `https://${app.fqdn}`)
|
||||||
|
: null,
|
||||||
|
status: app.status ?? 'unknown',
|
||||||
|
coolifyUuid: app.uuid,
|
||||||
|
gitRepo: app.git_repository ?? null,
|
||||||
|
}));
|
||||||
|
return NextResponse.json({ apps, source: 'coolify' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: use whatever URL was stored by the Coolify webhook
|
||||||
|
const lastDeployment = (data as any).contextSnapshot?.lastDeployment ?? null;
|
||||||
|
if (lastDeployment?.url) {
|
||||||
|
const apps: PreviewApp[] = [{
|
||||||
|
name: giteaRepo.split('/').pop() ?? 'app',
|
||||||
|
url: lastDeployment.url.startsWith('http') ? lastDeployment.url : `https://${lastDeployment.url}`,
|
||||||
|
status: lastDeployment.status === 'finished' ? 'running' : lastDeployment.status ?? 'unknown',
|
||||||
|
coolifyUuid: lastDeployment.applicationUuid ?? null,
|
||||||
|
gitRepo: giteaRepo,
|
||||||
|
}];
|
||||||
|
return NextResponse.json({ apps, source: 'webhook' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
apps: [],
|
||||||
|
message: `No Coolify app found for repo: ${giteaRepo}`,
|
||||||
|
giteaRepo,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -10,7 +9,7 @@ export async function GET(
|
|||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -45,7 +44,7 @@ export async function PATCH(
|
|||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getServerSession } from "next-auth/next";
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from "@/lib/auth/authOptions";
|
|
||||||
import { query } from "@/lib/db-postgres";
|
import { query } from "@/lib/db-postgres";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -11,7 +10,7 @@ export async function POST(
|
|||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,7 @@ export async function GET(
|
|||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ projectId: string }> }
|
{ params }: { params: Promise<{ projectId: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -9,7 +8,7 @@ export async function POST(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
|
||||||
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
|
||||||
|
|
||||||
export async function POST(
|
|
||||||
_request: Request,
|
|
||||||
{ params }: { params: Promise<{ projectId: string }> },
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { projectId } = await params;
|
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user?.email) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify ownership
|
|
||||||
const rows = await query<{ id: string; data: any }>(`
|
|
||||||
SELECT p.id, p.data
|
|
||||||
FROM fs_projects p
|
|
||||||
JOIN fs_users u ON u.id = p.user_id
|
|
||||||
WHERE p.id = $1 AND u.data->>'email' = $2
|
|
||||||
LIMIT 1
|
|
||||||
`, [projectId, session.user.email]);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = rows[0].data;
|
|
||||||
|
|
||||||
if (project.theiaWorkspaceUrl) {
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
workspaceUrl: project.theiaWorkspaceUrl,
|
|
||||||
message: 'Workspace already provisioned',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = project.slug;
|
|
||||||
if (!slug) {
|
|
||||||
return NextResponse.json({ error: 'Project has no slug — cannot provision workspace' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision Cloud Run workspace
|
|
||||||
const workspace = await provisionTheiaWorkspace(slug, projectId, project.giteaRepo ?? null);
|
|
||||||
|
|
||||||
// Save URL back to project record
|
|
||||||
await query(`
|
|
||||||
UPDATE fs_projects
|
|
||||||
SET data = data || jsonb_build_object(
|
|
||||||
'theiaWorkspaceUrl', $1::text,
|
|
||||||
'theiaAppUuid', $2::text
|
|
||||||
)
|
|
||||||
WHERE id = $3
|
|
||||||
`, [workspace.serviceUrl, workspace.serviceName, projectId]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
workspaceUrl: workspace.serviceUrl,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[POST /api/projects/:id/workspace] Error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to provision workspace', details: error instanceof Error ? error.message : String(error) },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
import { createRepo, createWebhook, getRepo, listWebhooks, GITEA_ADMIN_USER_EXPORT } from '@/lib/gitea';
|
||||||
import { pushTurborepoScaffold } from '@/lib/scaffold';
|
import { pushTurborepoScaffold } from '@/lib/scaffold';
|
||||||
import { createProject as createCoolifyProject, createMonorepoAppService } from '@/lib/coolify';
|
import { createMonorepoAppService } from '@/lib/coolify';
|
||||||
import { provisionTheiaWorkspace } from '@/lib/cloud-run-workspace';
|
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
||||||
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
import type { ProjectPhaseData, ProjectPhaseScores } from '@/lib/types/project-artifacts';
|
||||||
|
|
||||||
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
const GITEA_ADMIN_USER = GITEA_ADMIN_USER_EXPORT;
|
||||||
@@ -15,7 +14,7 @@ const GITEA_WEBHOOK_SECRET = process.env.GITEA_WEBHOOK_SECRET ?? 'vibn-webhook-s
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
@@ -53,6 +52,19 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const firebaseUserId = users[0]!.id;
|
const firebaseUserId = users[0]!.id;
|
||||||
|
|
||||||
|
// Resolve (and lazily provision) the user's workspace. Provides:
|
||||||
|
// - vibnWorkspace.coolify_project_uuid → namespace for Coolify apps/DBs
|
||||||
|
// - vibnWorkspace.gitea_org → owner for Gitea repos
|
||||||
|
// If provisioning failed for either, we fall back to legacy admin
|
||||||
|
// identifiers so the project create still succeeds (with degraded isolation).
|
||||||
|
let vibnWorkspace = await getOrCreateProvisionedWorkspace({
|
||||||
|
userId: firebaseUserId,
|
||||||
|
email,
|
||||||
|
displayName: session.user.name ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoOwner = vibnWorkspace.gitea_org ?? GITEA_ADMIN_USER;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const {
|
const {
|
||||||
projectName,
|
projectName,
|
||||||
@@ -66,6 +78,7 @@ export async function POST(request: Request) {
|
|||||||
githubRepoId,
|
githubRepoId,
|
||||||
githubRepoUrl,
|
githubRepoUrl,
|
||||||
githubDefaultBranch,
|
githubDefaultBranch,
|
||||||
|
githubToken,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
// Check slug uniqueness
|
// Check slug uniqueness
|
||||||
@@ -96,14 +109,15 @@ export async function POST(request: Request) {
|
|||||||
description: `${projectName} — managed by Vibn`,
|
description: `${projectName} — managed by Vibn`,
|
||||||
private: true,
|
private: true,
|
||||||
auto_init: false,
|
auto_init: false,
|
||||||
|
owner: repoOwner,
|
||||||
});
|
});
|
||||||
console.log(`[API] Gitea repo created: ${GITEA_ADMIN_USER}/${repoName}`);
|
console.log(`[API] Gitea repo created: ${repoOwner}/${repoName}`);
|
||||||
} catch (createErr) {
|
} catch (createErr) {
|
||||||
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
const msg = createErr instanceof Error ? createErr.message : String(createErr);
|
||||||
// 409 = repo already exists — link to it instead of failing
|
// 409 = repo already exists — link to it instead of failing
|
||||||
if (msg.includes('409')) {
|
if (msg.includes('409')) {
|
||||||
console.log(`[API] Gitea repo already exists, linking to ${GITEA_ADMIN_USER}/${repoName}`);
|
console.log(`[API] Gitea repo already exists, linking to ${repoOwner}/${repoName}`);
|
||||||
repo = await getRepo(GITEA_ADMIN_USER, repoName);
|
repo = await getRepo(repoOwner, repoName);
|
||||||
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
|
if (!repo) throw new Error(`Repo ${repoName} exists but could not be fetched`);
|
||||||
} else {
|
} else {
|
||||||
throw createErr;
|
throw createErr;
|
||||||
@@ -115,17 +129,37 @@ export async function POST(request: Request) {
|
|||||||
giteaCloneUrl = repo.clone_url;
|
giteaCloneUrl = repo.clone_url;
|
||||||
giteaSshUrl = repo.ssh_url;
|
giteaSshUrl = repo.ssh_url;
|
||||||
|
|
||||||
// Push Turborepo monorepo scaffold as initial commit
|
// If a GitHub repo was provided, mirror it as-is.
|
||||||
await pushTurborepoScaffold(GITEA_ADMIN_USER, repoName, slug, projectName);
|
// Otherwise push the default Turborepo scaffold.
|
||||||
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
if (githubRepoUrl) {
|
||||||
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
const mirrorRes = await fetch(`${agentRunnerUrl}/api/mirror`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
github_url: githubRepoUrl,
|
||||||
|
gitea_repo: `${repoOwner}/${repoName}`,
|
||||||
|
project_name: projectName,
|
||||||
|
github_token: githubToken || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!mirrorRes.ok) {
|
||||||
|
const detail = await mirrorRes.text();
|
||||||
|
throw new Error(`GitHub mirror failed: ${detail}`);
|
||||||
|
}
|
||||||
|
console.log(`[API] GitHub repo mirrored to ${giteaRepo}`);
|
||||||
|
} else {
|
||||||
|
await pushTurborepoScaffold(repoOwner, repoName, slug, projectName);
|
||||||
|
console.log(`[API] Turborepo scaffold pushed to ${giteaRepo}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Register webhook — skip if one already points to this project
|
// Register webhook — skip if one already points to this project
|
||||||
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
const webhookUrl = `${APP_URL}/api/webhooks/gitea?projectId=${projectId}`;
|
||||||
const existingHooks = await listWebhooks(GITEA_ADMIN_USER, repoName).catch(() => []);
|
const existingHooks = await listWebhooks(repoOwner, repoName).catch(() => []);
|
||||||
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
|
const alreadyHooked = existingHooks.some(h => h.config.url.includes(projectId));
|
||||||
|
|
||||||
if (!alreadyHooked) {
|
if (!alreadyHooked) {
|
||||||
const hook = await createWebhook(GITEA_ADMIN_USER, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
|
const hook = await createWebhook(repoOwner, repoName, webhookUrl, GITEA_WEBHOOK_SECRET);
|
||||||
giteaWebhookId = hook.id;
|
giteaWebhookId = hook.id;
|
||||||
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
|
console.log(`[API] Webhook registered: ${giteaRepo}, id: ${giteaWebhookId}`);
|
||||||
} else {
|
} else {
|
||||||
@@ -139,7 +173,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 2. Provision Coolify project + per-app services
|
// 2. Provision per-app services under the workspace's Coolify Project
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
|
const APP_BASE_DOMAIN = process.env.APP_BASE_DOMAIN ?? 'vibnai.com';
|
||||||
const appNames = ['product', 'website', 'admin', 'storybook'] as const;
|
const appNames = ['product', 'website', 'admin', 'storybook'] as const;
|
||||||
@@ -147,53 +181,33 @@ export async function POST(request: Request) {
|
|||||||
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
|
name: string; path: string; coolifyServiceUuid: string | null; domain: string | null;
|
||||||
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
|
}> = appNames.map(name => ({ name, path: `apps/${name}`, coolifyServiceUuid: null, domain: null }));
|
||||||
|
|
||||||
if (giteaCloneUrl) {
|
// The workspace's Coolify Project IS our team boundary. All Vibn
|
||||||
try {
|
// projects for a workspace share one Coolify Project namespace.
|
||||||
const coolifyProject = await createCoolifyProject(
|
const coolifyProjectUuid: string | null = vibnWorkspace.coolify_project_uuid;
|
||||||
projectName,
|
|
||||||
`Vibn project: ${projectName}`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const app of provisionedApps) {
|
if (giteaCloneUrl && coolifyProjectUuid) {
|
||||||
try {
|
for (const app of provisionedApps) {
|
||||||
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
try {
|
||||||
const service = await createMonorepoAppService({
|
const domain = `${app.name}-${slug}.${APP_BASE_DOMAIN}`;
|
||||||
projectUuid: coolifyProject.uuid,
|
const service = await createMonorepoAppService({
|
||||||
appName: app.name,
|
projectUuid: coolifyProjectUuid,
|
||||||
gitRepo: giteaCloneUrl,
|
appName: `${slug}-${app.name}`, // unique within the workspace's Coolify Project
|
||||||
domain,
|
gitRepo: giteaCloneUrl,
|
||||||
});
|
domain,
|
||||||
app.coolifyServiceUuid = service.uuid;
|
});
|
||||||
app.domain = domain;
|
app.coolifyServiceUuid = service.uuid;
|
||||||
console.log(`[API] Coolify service created: ${app.name} → ${domain}`);
|
app.domain = domain;
|
||||||
} catch (appErr) {
|
console.log(`[API] Coolify service created: ${app.name} → ${domain}`);
|
||||||
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
} catch (appErr) {
|
||||||
}
|
console.error(`[API] Coolify service failed for ${app.name}:`, appErr);
|
||||||
}
|
}
|
||||||
} catch (coolifyErr) {
|
|
||||||
console.error('[API] Coolify project provisioning failed (non-fatal):', coolifyErr);
|
|
||||||
}
|
}
|
||||||
|
} else if (!coolifyProjectUuid) {
|
||||||
|
console.warn('[API] Workspace has no Coolify Project UUID — skipped app provisioning. Run /api/workspaces/{slug}/provision to retry.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// 3. Provision dedicated Theia workspace
|
// 3. Save project record
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
let theiaWorkspaceUrl: string | null = null;
|
|
||||||
let theiaAppUuid: string | null = null;
|
|
||||||
let theiaError: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const workspace = await provisionTheiaWorkspace(slug, projectId, giteaRepo);
|
|
||||||
theiaWorkspaceUrl = workspace.serviceUrl;
|
|
||||||
theiaAppUuid = workspace.serviceName;
|
|
||||||
console.log(`[API] Theia workspace provisioned: ${theiaWorkspaceUrl}`);
|
|
||||||
} catch (err) {
|
|
||||||
theiaError = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error('[API] Theia workspace provisioning failed (non-fatal):', theiaError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
|
||||||
// 4. Save project record
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
const projectData = {
|
const projectData = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
@@ -230,23 +244,25 @@ export async function POST(request: Request) {
|
|||||||
giteaSshUrl,
|
giteaSshUrl,
|
||||||
giteaWebhookId,
|
giteaWebhookId,
|
||||||
giteaError,
|
giteaError,
|
||||||
// Theia workspace
|
|
||||||
theiaWorkspaceUrl,
|
|
||||||
theiaAppUuid,
|
|
||||||
theiaError,
|
|
||||||
// Context snapshot (kept fresh by webhooks)
|
// Context snapshot (kept fresh by webhooks)
|
||||||
contextSnapshot: null,
|
contextSnapshot: null,
|
||||||
|
// Coolify project — one per VIBN project, scopes all app services + DBs
|
||||||
|
coolifyProjectUuid,
|
||||||
// Turborepo monorepo apps — each gets its own Coolify service
|
// Turborepo monorepo apps — each gets its own Coolify service
|
||||||
turboVersion: '2.3.3',
|
turboVersion: '2.3.3',
|
||||||
apps: provisionedApps,
|
apps: provisionedApps,
|
||||||
|
// Import metadata
|
||||||
|
isImport: !!githubRepoUrl,
|
||||||
|
importAnalysisStatus: githubRepoUrl ? 'pending' : null,
|
||||||
|
importAnalysisJobId: null as string | null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO fs_projects (id, data, user_id, workspace, slug)
|
INSERT INTO fs_projects (id, data, user_id, workspace, slug, vibn_workspace_id)
|
||||||
VALUES ($1, $2::jsonb, $3, $4, $5)
|
VALUES ($1, $2::jsonb, $3, $4, $5, $6)
|
||||||
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug]);
|
`, [projectId, JSON.stringify(projectData), firebaseUserId, workspace, slug, vibnWorkspace.id]);
|
||||||
|
|
||||||
// Associate any unlinked sessions for this workspace path
|
// Associate any unlinked sessions for this workspace path
|
||||||
if (workspacePath) {
|
if (workspacePath) {
|
||||||
@@ -262,7 +278,40 @@ export async function POST(request: Request) {
|
|||||||
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
`, [JSON.stringify(projectId), firebaseUserId, workspacePath]);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped');
|
// ──────────────────────────────────────────────
|
||||||
|
// 5. If this is an import, trigger the analysis agent
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
let analysisJobId: string | null = null;
|
||||||
|
if (githubRepoUrl && giteaRepo) {
|
||||||
|
try {
|
||||||
|
const agentRunnerUrl = process.env.AGENT_RUNNER_URL ?? 'http://localhost:3333';
|
||||||
|
const jobRes = await fetch(`${agentRunnerUrl}/api/agent/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
agent: 'ImportAnalyzer',
|
||||||
|
task: `Analyze this imported codebase (originally from ${githubRepoUrl}) and produce CODEBASE_MAP.md and MIGRATION_PLAN.md as described in your instructions.`,
|
||||||
|
repo: giteaRepo,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (jobRes.ok) {
|
||||||
|
const jobData = await jobRes.json() as { jobId?: string };
|
||||||
|
analysisJobId = jobData.jobId ?? null;
|
||||||
|
// Store the job ID on the project record
|
||||||
|
if (analysisJobId) {
|
||||||
|
await query(
|
||||||
|
`UPDATE fs_projects SET data = jsonb_set(jsonb_set(data, '{importAnalysisJobId}', $1::jsonb), '{importAnalysisStatus}', '"running"') WHERE id = $2`,
|
||||||
|
[JSON.stringify(analysisJobId), projectId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[API] Import analysis job started: ${analysisJobId}`);
|
||||||
|
}
|
||||||
|
} catch (analysisErr) {
|
||||||
|
console.error('[API] Failed to start import analysis (non-fatal):', analysisErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] Created project', projectId, slug, '| gitea:', giteaRepo ?? 'skipped', '| import:', !!githubRepoUrl);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -273,8 +322,8 @@ export async function POST(request: Request) {
|
|||||||
? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl }
|
? { repo: giteaRepo, repoUrl: giteaRepoUrl, cloneUrl: giteaCloneUrl, sshUrl: giteaSshUrl }
|
||||||
: null,
|
: null,
|
||||||
giteaError: giteaError ?? undefined,
|
giteaError: giteaError ?? undefined,
|
||||||
theiaWorkspaceUrl,
|
isImport: !!githubRepoUrl,
|
||||||
theiaError: theiaError ?? undefined,
|
analysisJobId: analysisJobId ?? undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[POST /api/projects/create] Error:', error);
|
console.error('[POST /api/projects/create] Error:', error);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { deployApplication } from '@/lib/coolify';
|
import { deployApplication } from '@/lib/coolify';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { prewarmWorkspace } from '@/lib/cloud-run-workspace';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/projects/prewarm
|
|
||||||
* Body: { urls: string[] }
|
|
||||||
*
|
|
||||||
* Fires warm-up requests to Cloud Run workspace URLs so containers
|
|
||||||
* are running by the time the user clicks "Open IDE". Server-side
|
|
||||||
* to avoid CORS issues with run.app domains.
|
|
||||||
*/
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { urls } = await req.json() as { urls: string[] };
|
|
||||||
if (!Array.isArray(urls) || urls.length === 0) {
|
|
||||||
return NextResponse.json({ warmed: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire all prewarm pings in parallel — intentionally not awaited
|
|
||||||
Promise.allSettled(urls.map(url => prewarmWorkspace(url))).catch(() => {});
|
|
||||||
|
|
||||||
return NextResponse.json({ warmed: urls.length });
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json([], { status: 200 });
|
return NextResponse.json([], { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* GET /api/theia-auth
|
|
||||||
*
|
|
||||||
* Traefik ForwardAuth endpoint for Theia IDE domains.
|
|
||||||
*
|
|
||||||
* Handles two cases:
|
|
||||||
* 1. theia.vibnai.com — shared IDE: any authenticated user may access
|
|
||||||
* 2. {slug}.ide.vibnai.com — per-project IDE: only the project owner may access
|
|
||||||
*
|
|
||||||
* Traefik calls this URL for every request to those Theia domains, forwarding
|
|
||||||
* the user's Cookie header via authRequestHeaders. We validate the NextAuth
|
|
||||||
* database session directly in Postgres (avoids Prisma / authOptions build-time
|
|
||||||
* issues under --network host).
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* 200 — valid session (and owner check passed), Traefik lets the request through
|
|
||||||
* 302 — no/expired session, redirect browser to Vibn login
|
|
||||||
* 403 — authenticated but not the project owner
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const APP_URL = process.env.NEXTAUTH_URL ?? 'https://vibnai.com';
|
|
||||||
const THEIA_URL = 'https://theia.vibnai.com';
|
|
||||||
const IDE_SUFFIX = '.ide.vibnai.com';
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAMES = [
|
|
||||||
'__Secure-next-auth.session-token',
|
|
||||||
'next-auth.session-token',
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
// ── 1. Extract session token ──────────────────────────────────────────────
|
|
||||||
let sessionToken: string | null = null;
|
|
||||||
for (const name of SESSION_COOKIE_NAMES) {
|
|
||||||
const val = request.cookies.get(name)?.value;
|
|
||||||
if (val) { sessionToken = val; break; }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionToken) return redirectToLogin(request);
|
|
||||||
|
|
||||||
// ── 2. Validate session in Postgres ──────────────────────────────────────
|
|
||||||
let userEmail: string | null = null;
|
|
||||||
let userName: string | null = null;
|
|
||||||
let userId: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await query<{ email: string; name: string; user_id: string }>(
|
|
||||||
`SELECT u.email, u.name, s.user_id
|
|
||||||
FROM sessions s
|
|
||||||
JOIN users u ON u.id = s.user_id
|
|
||||||
WHERE s.session_token = $1
|
|
||||||
AND s.expires > NOW()
|
|
||||||
LIMIT 1`,
|
|
||||||
[sessionToken],
|
|
||||||
);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
userEmail = rows[0].email;
|
|
||||||
userName = rows[0].name;
|
|
||||||
userId = rows[0].user_id;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[theia-auth] DB error:', err);
|
|
||||||
return redirectToLogin(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userEmail || !userId) return redirectToLogin(request);
|
|
||||||
|
|
||||||
// ── 3. Per-project ownership check for *.ide.vibnai.com ──────────────────
|
|
||||||
const forwardedHost = request.headers.get('x-forwarded-host') ?? '';
|
|
||||||
|
|
||||||
if (forwardedHost.endsWith(IDE_SUFFIX)) {
|
|
||||||
const slug = forwardedHost.slice(0, -IDE_SUFFIX.length);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await query<{ user_id: string }>(
|
|
||||||
`SELECT user_id FROM fs_projects WHERE slug = $1 LIMIT 1`,
|
|
||||||
[slug],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
// Unknown project slug — deny
|
|
||||||
return new NextResponse('Workspace not found', { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerUserId = rows[0].user_id;
|
|
||||||
if (ownerUserId !== userId) {
|
|
||||||
// Authenticated but not the owner
|
|
||||||
return new NextResponse('Access denied — this workspace belongs to another user', { status: 403 });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[theia-auth] project ownership check error:', err);
|
|
||||||
return redirectToLogin(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Allow — pass user identity headers to Theia ───────────────────────
|
|
||||||
return new NextResponse(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'X-Auth-Email': userEmail,
|
|
||||||
'X-Auth-Name': userName ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirectToLogin(request: NextRequest): NextResponse {
|
|
||||||
// Use THEIA_URL as the callbackUrl so the user lands back on Theia after login.
|
|
||||||
// (X-Forwarded-Host points to vibnai.com via Traefik, not the original Theia domain.)
|
|
||||||
const loginUrl = `${APP_URL}/auth?callbackUrl=${encodeURIComponent(THEIA_URL)}`;
|
|
||||||
return NextResponse.redirect(loginUrl, { status: 302 });
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getServerSession } from 'next-auth';
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
import { authOptions } from '@/lib/auth/authOptions';
|
|
||||||
import { query } from '@/lib/db-postgres';
|
import { query } from '@/lib/db-postgres';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await authSession();
|
||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 });
|
return NextResponse.json({ error: 'No authorization token provided' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
47
app/api/workspaces/[slug]/apps/[uuid]/deploy/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/apps/[uuid]/deploy
|
||||||
|
*
|
||||||
|
* Trigger a deploy on a Coolify app. Guard: app must belong to this
|
||||||
|
* workspace's Coolify project before we forward the call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
deployApplication,
|
||||||
|
getApplicationInProject,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tenant check before any mutation.
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const result = await deployApplication(uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deploymentUuid: result.deployment_uuid,
|
||||||
|
appUuid: uuid,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Deploy failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
41
app/api/workspaces/[slug]/apps/[uuid]/deployments/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/deployments
|
||||||
|
*
|
||||||
|
* Recent deployments for an app. Tenant-checked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
listApplicationDeployments,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const deployments = await listApplicationDeployments(uuid);
|
||||||
|
return NextResponse.json({ deployments });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts
Normal file
114
app/api/workspaces/[slug]/apps/[uuid]/domains/route.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/domains — list current domains
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid]/domains — replace domain set
|
||||||
|
*
|
||||||
|
* Body: { domains: string[] } — each must end with .{workspace}.vibnai.com.
|
||||||
|
* We enforce workspace-subdomain policy here to prevent AI-driven
|
||||||
|
* hijacking of other workspaces' subdomains.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
setApplicationDomains,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import {
|
||||||
|
isDomainUnderWorkspace,
|
||||||
|
parseDomainsString,
|
||||||
|
workspaceAppFqdn,
|
||||||
|
slugify,
|
||||||
|
} from '@/lib/naming';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
domains: parseDomainsString(app.domains ?? app.fqdn ?? ''),
|
||||||
|
workspaceDomainSuffix: `${ws.slug}.vibnai.com`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { domains?: string[] } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Array.isArray(body.domains) ? body.domains : [];
|
||||||
|
if (raw.length === 0) {
|
||||||
|
return NextResponse.json({ error: '`domains` must be a non-empty array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize + policy-check.
|
||||||
|
const normalized: string[] = [];
|
||||||
|
for (const d of raw) {
|
||||||
|
if (typeof d !== 'string' || !d.trim()) continue;
|
||||||
|
const clean = d.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
|
||||||
|
if (!isDomainUnderWorkspace(clean, ws.slug)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Domain ${clean} is not allowed; must end with .${ws.slug}.vibnai.com`,
|
||||||
|
hint: `Use ${workspaceAppFqdn(ws.slug, slugify(app.name))}`,
|
||||||
|
},
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
normalized.push(clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setApplicationDomains(uuid, normalized, { forceOverride: true });
|
||||||
|
return NextResponse.json({ ok: true, uuid, domains: normalized });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify domain update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
176
app/api/workspaces/[slug]/apps/[uuid]/envs/route.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/envs — list env vars
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid]/envs — upsert one env var
|
||||||
|
* body: { key, value, is_preview?, is_literal?, is_multiline?, is_shown_once? }
|
||||||
|
* DELETE /api/workspaces/[slug]/apps/[uuid]/envs?key=FOO — delete one env var
|
||||||
|
*
|
||||||
|
* Tenant boundary: the app must belong to the workspace's Coolify project.
|
||||||
|
*
|
||||||
|
* NOTE: `is_build_time` is **not** a writable flag in Coolify v4 — it's a
|
||||||
|
* derived read-only attribute. We silently drop it from incoming request
|
||||||
|
* bodies for back-compat with older agents; the value is computed by
|
||||||
|
* Coolify at build time based on Dockerfile ARG usage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
deleteApplicationEnv,
|
||||||
|
getApplicationInProject,
|
||||||
|
listApplicationEnvs,
|
||||||
|
TenantError,
|
||||||
|
upsertApplicationEnv,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
async function verify(request: Request, slug: string, uuid: string) {
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return { error: principal };
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet' },
|
||||||
|
{ status: 503 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return { error: NextResponse.json({ error: err.message }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { principal };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envs = await listApplicationEnvs(uuid);
|
||||||
|
// Redact values by default for API-key callers — they can re-fetch
|
||||||
|
// with ?reveal=true when they need the actual values (e.g. to copy
|
||||||
|
// a DATABASE_URL). Session callers always get full values.
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const reveal =
|
||||||
|
check.principal.source === 'session' || url.searchParams.get('reveal') === 'true';
|
||||||
|
return NextResponse.json({
|
||||||
|
envs: envs.map(e => ({
|
||||||
|
key: e.key,
|
||||||
|
value: reveal ? e.value : maskValue(e.value),
|
||||||
|
isPreview: e.is_preview ?? false,
|
||||||
|
// Coolify spells the read-only build-time flag two different ways
|
||||||
|
// depending on version — `is_buildtime` (new, one word) and
|
||||||
|
// `is_build_time` (old, underscored). Fall through both.
|
||||||
|
isBuildTime: e.is_buildtime ?? e.is_build_time ?? false,
|
||||||
|
isRuntime: e.is_runtime ?? true,
|
||||||
|
isLiteral: e.is_literal ?? false,
|
||||||
|
isMultiline: e.is_multiline ?? false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
is_preview?: boolean;
|
||||||
|
/** @deprecated silently dropped — Coolify no longer accepts this on write. */
|
||||||
|
is_build_time?: boolean;
|
||||||
|
is_literal?: boolean;
|
||||||
|
is_multiline?: boolean;
|
||||||
|
is_shown_once?: boolean;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.key || typeof body.value !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Fields "key" and "value" are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const env = await upsertApplicationEnv(uuid, {
|
||||||
|
key: body.key,
|
||||||
|
value: body.value,
|
||||||
|
is_preview: body.is_preview ?? false,
|
||||||
|
is_literal: body.is_literal ?? false,
|
||||||
|
is_multiline: body.is_multiline ?? false,
|
||||||
|
is_shown_once: body.is_shown_once ?? false,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
key: env.key,
|
||||||
|
// Soft-deprecation signal so the caller's agent can learn to stop
|
||||||
|
// sending the flag without hard-breaking today.
|
||||||
|
warnings:
|
||||||
|
body.is_build_time !== undefined
|
||||||
|
? [
|
||||||
|
'is_build_time is ignored — Coolify derives build-vs-runtime from Dockerfile ARG usage. Omit this field going forward.',
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const check = await verify(request, slug, uuid);
|
||||||
|
if ('error' in check) return check.error;
|
||||||
|
|
||||||
|
const key = new URL(request.url).searchParams.get('key');
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json({ error: 'Query param "key" is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteApplicationEnv(uuid, key);
|
||||||
|
return NextResponse.json({ ok: true, key });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskValue(v: string): string {
|
||||||
|
if (!v) return '';
|
||||||
|
if (v.length <= 4) return '•'.repeat(v.length);
|
||||||
|
return `${v.slice(0, 2)}${'•'.repeat(Math.min(v.length - 4, 10))}${v.slice(-2)}`;
|
||||||
|
}
|
||||||
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/apps/[uuid]/exec
|
||||||
|
*
|
||||||
|
* Run a one-shot command inside an app container via `docker exec`
|
||||||
|
* on the Coolify host (over SSH). The companion of `/logs` for the
|
||||||
|
* write path.
|
||||||
|
*
|
||||||
|
* Body (JSON):
|
||||||
|
* command string required. Passed through `sh -lc`.
|
||||||
|
* service string? compose service to target (required when
|
||||||
|
* the app has >1 container).
|
||||||
|
* user string? docker exec --user
|
||||||
|
* workdir string? docker exec --workdir
|
||||||
|
* timeout_ms number? default 60_000, max 600_000
|
||||||
|
* max_bytes number? default 1_000_000, max 5_000_000
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { getApplicationInProject, TenantError } from '@/lib/coolify';
|
||||||
|
import { execInCoolifyApp } from '@/lib/coolify-exec';
|
||||||
|
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
if (!isCoolifySshConfigured()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'apps.exec requires SSH to the Coolify host, not configured on this deployment.' },
|
||||||
|
{ status: 501 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Body must be JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = typeof body.command === 'string' ? body.command : '';
|
||||||
|
if (!command) {
|
||||||
|
return NextResponse.json({ error: 'command is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const service = typeof body.service === 'string' && body.service.trim()
|
||||||
|
? body.service.trim()
|
||||||
|
: undefined;
|
||||||
|
const user = typeof body.user === 'string' && body.user.trim() ? body.user.trim() : undefined;
|
||||||
|
const workdir = typeof body.workdir === 'string' && body.workdir.trim()
|
||||||
|
? body.workdir.trim()
|
||||||
|
: undefined;
|
||||||
|
const timeoutMs = Number.isFinite(Number(body.timeout_ms)) ? Number(body.timeout_ms) : undefined;
|
||||||
|
const maxBytes = Number.isFinite(Number(body.max_bytes)) ? Number(body.max_bytes) : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const result = await execInCoolifyApp({
|
||||||
|
appUuid: uuid,
|
||||||
|
command,
|
||||||
|
service,
|
||||||
|
user,
|
||||||
|
workdir,
|
||||||
|
timeoutMs,
|
||||||
|
maxBytes,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to exec in container', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts
Normal file
49
app/api/workspaces/[slug]/apps/[uuid]/logs/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid]/logs
|
||||||
|
*
|
||||||
|
* Runtime logs for a Coolify app. Compose-aware: Coolify's REST API
|
||||||
|
* for non-compose build packs, SSH into the Coolify host for per-
|
||||||
|
* service `docker logs` on compose apps.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* lines – tail lines per container (default 200, max 5000)
|
||||||
|
* service – limit to one compose service (optional)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { getApplicationInProject, TenantError } from '@/lib/coolify';
|
||||||
|
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const linesRaw = Number(url.searchParams.get('lines') ?? '200');
|
||||||
|
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
|
||||||
|
const service = url.searchParams.get('service') ?? undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
const result = await getApplicationRuntimeLogs(uuid, { lines, service });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch runtime logs', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
181
app/api/workspaces/[slug]/apps/[uuid]/route.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps/[uuid] — app details
|
||||||
|
* PATCH /api/workspaces/[slug]/apps/[uuid] — update fields (name/branch/build config)
|
||||||
|
* DELETE /api/workspaces/[slug]/apps/[uuid]?confirm=<name>
|
||||||
|
* — destroy app. Volumes kept by default.
|
||||||
|
*
|
||||||
|
* All verify the app's project uuid matches the workspace's before
|
||||||
|
* acting. DELETE additionally requires `?confirm=<exact-resource-name>`
|
||||||
|
* to prevent AI-driven accidents.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
updateApplication,
|
||||||
|
deleteApplication,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
status: app.status,
|
||||||
|
fqdn: app.fqdn ?? null,
|
||||||
|
domains: app.domains ?? null,
|
||||||
|
gitRepository: app.git_repository ?? null,
|
||||||
|
gitBranch: app.git_branch ?? null,
|
||||||
|
projectUuid: projectUuidOf(app),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenancy first (400-style fail fast on cross-tenant access).
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist which Coolify fields we expose to AI-level callers.
|
||||||
|
// Domains are managed via the dedicated /domains subroute.
|
||||||
|
const allowed = new Set([
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'git_branch',
|
||||||
|
'build_pack',
|
||||||
|
'ports_exposes',
|
||||||
|
'install_command',
|
||||||
|
'build_command',
|
||||||
|
'start_command',
|
||||||
|
'base_directory',
|
||||||
|
'dockerfile_location',
|
||||||
|
'is_auto_deploy_enabled',
|
||||||
|
'is_force_https_enabled',
|
||||||
|
'static_image',
|
||||||
|
]);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (allowed.has(k) && v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateApplication(uuid, patch);
|
||||||
|
return NextResponse.json({ ok: true, uuid });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the app and verify tenancy.
|
||||||
|
let app;
|
||||||
|
try {
|
||||||
|
app = await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'App not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require `?confirm=<exact app name>` to prevent accidental destroys.
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== app.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Confirmation required',
|
||||||
|
hint: `Pass ?confirm=${app.name} to delete this app`,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: preserve volumes (user data). Caller can opt in.
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
|
||||||
|
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
|
||||||
|
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteApplication(uuid, {
|
||||||
|
deleteConfigurations,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks,
|
||||||
|
dockerCleanup,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, deleted: { uuid, name: app.name, volumesKept: !deleteVolumes } });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
232
app/api/workspaces/[slug]/apps/route.ts
Normal file
232
app/api/workspaces/[slug]/apps/route.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/apps — list Coolify apps in this workspace
|
||||||
|
* POST /api/workspaces/[slug]/apps — create a new app from a Gitea repo
|
||||||
|
*
|
||||||
|
* Auth: session OR `Bearer vibn_sk_...`. The workspace's
|
||||||
|
* `coolify_project_uuid` acts as the tenant boundary — any app whose
|
||||||
|
* Coolify project uuid doesn't match is filtered out even if the
|
||||||
|
* token issuer accidentally had wider reach.
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* repo: string, // "my-api" or "{org}/my-api"
|
||||||
|
* branch?: string, // default: "main"
|
||||||
|
* name?: string, // default: derived from repo
|
||||||
|
* ports?: string, // default: "3000"
|
||||||
|
* buildPack?: "nixpacks"|"static"|"dockerfile"|"dockercompose"
|
||||||
|
* domain?: string, // default: {app}.{workspace}.vibnai.com
|
||||||
|
* envs?: Record<string,string>
|
||||||
|
* instantDeploy?: boolean, // default: true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listApplicationsInProject,
|
||||||
|
projectUuidOf,
|
||||||
|
createPrivateDeployKeyApp,
|
||||||
|
upsertApplicationEnv,
|
||||||
|
getApplication,
|
||||||
|
deployApplication,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import {
|
||||||
|
slugify,
|
||||||
|
workspaceAppFqdn,
|
||||||
|
toDomainsString,
|
||||||
|
isDomainUnderWorkspace,
|
||||||
|
giteaSshUrl,
|
||||||
|
} from '@/lib/naming';
|
||||||
|
import { getRepo } from '@/lib/gitea';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', apps: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apps = await listApplicationsInProject(ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
apps: apps.map(a => ({
|
||||||
|
uuid: a.uuid,
|
||||||
|
name: a.name,
|
||||||
|
status: a.status,
|
||||||
|
fqdn: a.fqdn ?? null,
|
||||||
|
domains: a.domains ?? null,
|
||||||
|
gitRepository: a.git_repository ?? null,
|
||||||
|
gitBranch: a.git_branch ?? null,
|
||||||
|
projectUuid: projectUuidOf(a),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (
|
||||||
|
!ws.coolify_project_uuid ||
|
||||||
|
!ws.coolify_private_key_uuid ||
|
||||||
|
!ws.gitea_org
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace not fully provisioned (need Coolify project + deploy key + Gitea org)' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
repo?: string;
|
||||||
|
branch?: string;
|
||||||
|
name?: string;
|
||||||
|
ports?: string;
|
||||||
|
buildPack?: 'nixpacks' | 'static' | 'dockerfile' | 'dockercompose';
|
||||||
|
domain?: string;
|
||||||
|
envs?: Record<string, string>;
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
description?: string;
|
||||||
|
baseDirectory?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
buildCommand?: string;
|
||||||
|
startCommand?: string;
|
||||||
|
dockerfileLocation?: string;
|
||||||
|
};
|
||||||
|
let body: Body = {};
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Body;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.repo || typeof body.repo !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Missing "repo" field' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either "repo-name" (assumed in workspace org) or "org/repo".
|
||||||
|
const parts = body.repo.replace(/\.git$/, '').split('/');
|
||||||
|
const repoOrg = parts.length === 2 ? parts[0] : ws.gitea_org;
|
||||||
|
const repoName = parts.length === 2 ? parts[1] : parts[0];
|
||||||
|
if (repoOrg !== ws.gitea_org) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Repo owner ${repoOrg} is not this workspace's org ${ws.gitea_org}` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid repo name' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the repo actually exists in Gitea (fail fast).
|
||||||
|
const repo = await getRepo(repoOrg, repoName);
|
||||||
|
if (!repo) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Repo ${repoOrg}/${repoName} not found in Gitea` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = slugify(body.name ?? repoName);
|
||||||
|
const fqdn = body.domain
|
||||||
|
? body.domain.replace(/^https?:\/\//, '')
|
||||||
|
: workspaceAppFqdn(ws.slug, appName);
|
||||||
|
if (!isDomainUnderWorkspace(fqdn, ws.slug)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Domain ${fqdn} must end with .${ws.slug}.vibnai.com` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createPrivateDeployKeyApp({
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
privateKeyUuid: ws.coolify_private_key_uuid,
|
||||||
|
gitRepository: giteaSshUrl(repoOrg, repoName),
|
||||||
|
gitBranch: body.branch ?? repo.default_branch ?? 'main',
|
||||||
|
portsExposes: body.ports ?? '3000',
|
||||||
|
buildPack: body.buildPack ?? 'nixpacks',
|
||||||
|
name: appName,
|
||||||
|
description: body.description ?? `AI-created from ${repoOrg}/${repoName}`,
|
||||||
|
domains: toDomainsString([fqdn]),
|
||||||
|
isAutoDeployEnabled: true,
|
||||||
|
isForceHttpsEnabled: true,
|
||||||
|
// We defer the first deploy until envs are attached so they
|
||||||
|
// show up in the initial build.
|
||||||
|
instantDeploy: false,
|
||||||
|
baseDirectory: body.baseDirectory,
|
||||||
|
installCommand: body.installCommand,
|
||||||
|
buildCommand: body.buildCommand,
|
||||||
|
startCommand: body.startCommand,
|
||||||
|
dockerfileLocation: body.dockerfileLocation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach env vars (best-effort — don't fail the whole create on one bad key).
|
||||||
|
if (body.envs && typeof body.envs === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(body.envs)) {
|
||||||
|
if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
|
||||||
|
try {
|
||||||
|
await upsertApplicationEnv(created.uuid, { key, value });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[apps.POST] upsert env failed', key, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now kick off the first deploy (unless the caller opted out).
|
||||||
|
let deploymentUuid: string | null = null;
|
||||||
|
if (body.instantDeploy !== false) {
|
||||||
|
try {
|
||||||
|
const dep = await deployApplication(created.uuid);
|
||||||
|
deploymentUuid = dep.deployment_uuid ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[apps.POST] initial deploy failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a hydrated object (status / urls) for the UI.
|
||||||
|
const app = await getApplication(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: app.uuid,
|
||||||
|
name: app.name,
|
||||||
|
status: app.status,
|
||||||
|
domain: fqdn,
|
||||||
|
url: `https://${fqdn}`,
|
||||||
|
gitRepository: app.git_repository ?? null,
|
||||||
|
gitBranch: app.git_branch ?? null,
|
||||||
|
deploymentUuid,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/api/workspaces/[slug]/auth/[uuid]/route.ts
Normal file
92
app/api/workspaces/[slug]/auth/[uuid]/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/auth/[uuid] — provider details
|
||||||
|
* DELETE /api/workspaces/[slug]/auth/[uuid]?confirm=<name>
|
||||||
|
* Volumes KEPT by default (don't blow away user accounts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getServiceInProject,
|
||||||
|
deleteService,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: svc.uuid,
|
||||||
|
name: svc.name,
|
||||||
|
status: svc.status ?? null,
|
||||||
|
projectUuid: projectUuidOf(svc),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let svc;
|
||||||
|
try {
|
||||||
|
svc = await getServiceInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== svc.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Confirmation required', hint: `Pass ?confirm=${svc.name}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteService(uuid, {
|
||||||
|
deleteConfigurations: true,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks: true,
|
||||||
|
dockerCleanup: true,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deleted: { uuid, name: svc.name, volumesKept: !deleteVolumes },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/api/workspaces/[slug]/auth/route.ts
Normal file
180
app/api/workspaces/[slug]/auth/route.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Workspace authentication providers.
|
||||||
|
*
|
||||||
|
* GET /api/workspaces/[slug]/auth — list auth-provider services
|
||||||
|
* POST /api/workspaces/[slug]/auth — provision one of the vetted providers
|
||||||
|
*
|
||||||
|
* AI-callers can only create providers from an allowlist — we deliberately
|
||||||
|
* skip the rest of Coolify's ~300 one-click templates so this endpoint
|
||||||
|
* stays focused on "auth for my app". The allowlist:
|
||||||
|
*
|
||||||
|
* pocketbase — lightweight (SQLite-backed) auth + data
|
||||||
|
* authentik — feature-rich self-hosted IDP
|
||||||
|
* keycloak — industry-standard OIDC/SAML
|
||||||
|
* keycloak-with-postgres
|
||||||
|
* pocket-id — passkey-first OIDC
|
||||||
|
* pocket-id-with-postgresql
|
||||||
|
* logto — dev-first IDP
|
||||||
|
* supertokens-with-postgresql — session/auth backend
|
||||||
|
*
|
||||||
|
* (Zitadel is not on Coolify's service catalog — callers that ask for
|
||||||
|
* it get a descriptive 400 so the AI knows to pick a supported one.)
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* { provider: "pocketbase", name?: "auth" }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listServicesInProject,
|
||||||
|
createService,
|
||||||
|
getService,
|
||||||
|
projectUuidOf,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import { slugify } from '@/lib/naming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vetted auth-provider service ids. Keys are what callers pass as
|
||||||
|
* `provider`; values are the Coolify service-template slugs.
|
||||||
|
*/
|
||||||
|
const AUTH_PROVIDERS: Record<string, string> = {
|
||||||
|
pocketbase: 'pocketbase',
|
||||||
|
authentik: 'authentik',
|
||||||
|
keycloak: 'keycloak',
|
||||||
|
'keycloak-with-postgres': 'keycloak-with-postgres',
|
||||||
|
'pocket-id': 'pocket-id',
|
||||||
|
'pocket-id-with-postgresql': 'pocket-id-with-postgresql',
|
||||||
|
logto: 'logto',
|
||||||
|
'supertokens-with-postgresql': 'supertokens-with-postgresql',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Anything in this set is Coolify-supported but not an auth provider (used for filtering the list view). */
|
||||||
|
const AUTH_PROVIDER_SLUGS = new Set(Object.values(AUTH_PROVIDERS));
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', providers: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const all = await listServicesInProject(ws.coolify_project_uuid);
|
||||||
|
// Coolify's list endpoint only returns summaries (no service_type) so
|
||||||
|
// we fetch each service individually to classify it by template slug.
|
||||||
|
// This is O(n) in services-per-workspace — acceptable at single-digit
|
||||||
|
// scales — and avoids name-based heuristics that break on custom names.
|
||||||
|
const detailed = await Promise.all(all.map(s => getService(s.uuid).catch(() => s)));
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
providers: detailed
|
||||||
|
.filter(s => {
|
||||||
|
const t = resolveProviderSlug(s);
|
||||||
|
return !!t && AUTH_PROVIDER_SLUGS.has(t);
|
||||||
|
})
|
||||||
|
.map(s => ({
|
||||||
|
uuid: s.uuid,
|
||||||
|
name: s.name,
|
||||||
|
status: s.status ?? null,
|
||||||
|
provider: resolveProviderSlug(s),
|
||||||
|
projectUuid: projectUuidOf(s),
|
||||||
|
})),
|
||||||
|
allowedProviders: Object.keys(AUTH_PROVIDERS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { provider?: string; name?: string; description?: string; instantDeploy?: boolean } = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerKey = (body.provider ?? '').toLowerCase().trim();
|
||||||
|
const coolifyType = AUTH_PROVIDERS[providerKey];
|
||||||
|
if (!coolifyType) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Unsupported provider "${providerKey}". Allowed: ${Object.keys(AUTH_PROVIDERS).join(', ')}`,
|
||||||
|
hint: 'Zitadel is not on Coolify v4 service catalog — use keycloak or authentik instead.',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = slugify(body.name ?? providerKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createService({
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
type: coolifyType,
|
||||||
|
name,
|
||||||
|
description: body.description ?? `AI-provisioned ${providerKey} for ${ws.slug}`,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
instantDeploy: body.instantDeploy ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = await getService(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: svc.uuid,
|
||||||
|
name: svc.name,
|
||||||
|
provider: providerKey,
|
||||||
|
status: svc.status ?? null,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authoritative: Coolify stores the template slug on `service_type`.
|
||||||
|
* Fall back to a name-prefix match so services created before that field
|
||||||
|
* existed still classify correctly.
|
||||||
|
*/
|
||||||
|
function resolveProviderSlug(svc: { name: string; service_type?: string }): string {
|
||||||
|
if (svc.service_type && AUTH_PROVIDER_SLUGS.has(svc.service_type)) return svc.service_type;
|
||||||
|
const candidates = Object.values(AUTH_PROVIDERS).sort((a, b) => b.length - a.length);
|
||||||
|
for (const slug of candidates) {
|
||||||
|
if (svc.name === slug || svc.name.startsWith(`${slug}-`) || svc.name.startsWith(`${slug}_`)) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
194
app/api/workspaces/[slug]/bootstrap.sh/route.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/bootstrap.sh
|
||||||
|
*
|
||||||
|
* One-shot installer. Intended usage inside a repo:
|
||||||
|
*
|
||||||
|
* curl -sSfL -H "Authorization: Bearer $VIBN_API_KEY" \
|
||||||
|
* https://vibnai.com/api/workspaces/<slug>/bootstrap.sh | sh
|
||||||
|
*
|
||||||
|
* Writes three files into the cwd:
|
||||||
|
* - .cursor/rules/vibn-workspace.mdc (system prompt for AI agents)
|
||||||
|
* - .cursor/mcp.json (registers /api/mcp as an MCP server)
|
||||||
|
* - .env.local (appends VIBN_* envs; never overwrites)
|
||||||
|
*
|
||||||
|
* Auth: caller MUST already have a `vibn_sk_...` token. We embed the
|
||||||
|
* same token in the generated mcp.json so Cursor agents can re-use it.
|
||||||
|
* Session auth works too but then nothing is embedded (the user gets
|
||||||
|
* placeholder strings to fill in themselves).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
|
||||||
|
const APP_BASE = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') ?? 'https://vibnai.com';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
const tokenFromHeader = extractBearer(request);
|
||||||
|
// For API-key callers we can safely echo the token they sent us
|
||||||
|
// back into the generated files. For session callers we emit a
|
||||||
|
// placeholder — we don't want to re-issue long-lived tokens from
|
||||||
|
// a cookie-authenticated browser request.
|
||||||
|
const embedToken =
|
||||||
|
principal.source === 'api_key' && tokenFromHeader
|
||||||
|
? tokenFromHeader
|
||||||
|
: '<paste your vibn_sk_ token here>';
|
||||||
|
|
||||||
|
const script = buildScript({
|
||||||
|
slug: ws.slug,
|
||||||
|
giteaOrg: ws.gitea_org ?? '(unprovisioned)',
|
||||||
|
coolifyProjectUuid: ws.coolify_project_uuid ?? '(unprovisioned)',
|
||||||
|
appBase: APP_BASE,
|
||||||
|
token: embedToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(script, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractBearer(request: Request): string | null {
|
||||||
|
const a = request.headers.get('authorization');
|
||||||
|
if (!a) return null;
|
||||||
|
const m = /^Bearer\s+(vibn_sk_[A-Za-z0-9_-]+)/i.exec(a.trim());
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScript(opts: {
|
||||||
|
slug: string;
|
||||||
|
giteaOrg: string;
|
||||||
|
coolifyProjectUuid: string;
|
||||||
|
appBase: string;
|
||||||
|
token: string;
|
||||||
|
}): string {
|
||||||
|
const { slug, giteaOrg, coolifyProjectUuid, appBase, token } = opts;
|
||||||
|
|
||||||
|
// Build the file bodies in TS so we can shell-escape them cleanly
|
||||||
|
// using base64. The script itself does no string interpolation on
|
||||||
|
// these payloads — it just decodes and writes.
|
||||||
|
const rule = buildCursorRule({ slug, giteaOrg, coolifyProjectUuid, appBase });
|
||||||
|
const mcp = JSON.stringify(
|
||||||
|
{
|
||||||
|
mcpServers: {
|
||||||
|
[`vibn-${slug}`]: {
|
||||||
|
url: `${appBase}/api/mcp`,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
const env = `VIBN_API_BASE=${appBase}\nVIBN_WORKSPACE=${slug}\nVIBN_API_KEY=${token}\n`;
|
||||||
|
|
||||||
|
const b64Rule = Buffer.from(rule, 'utf8').toString('base64');
|
||||||
|
const b64Mcp = Buffer.from(mcp, 'utf8').toString('base64');
|
||||||
|
const b64Env = Buffer.from(env, 'utf8').toString('base64');
|
||||||
|
|
||||||
|
return `#!/usr/bin/env sh
|
||||||
|
# Vibn workspace bootstrap — generated ${new Date().toISOString()}
|
||||||
|
# Workspace: ${slug}
|
||||||
|
#
|
||||||
|
# Writes .cursor/rules/vibn-workspace.mdc, .cursor/mcp.json,
|
||||||
|
# and appends VIBN_* env vars to .env.local (never overwrites).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
mkdir -p .cursor/rules
|
||||||
|
|
||||||
|
echo "${b64Rule}" | base64 -d > .cursor/rules/vibn-workspace.mdc
|
||||||
|
echo " wrote .cursor/rules/vibn-workspace.mdc"
|
||||||
|
|
||||||
|
echo "${b64Mcp}" | base64 -d > .cursor/mcp.json
|
||||||
|
echo " wrote .cursor/mcp.json"
|
||||||
|
|
||||||
|
if [ -f .env.local ] && grep -q '^VIBN_API_BASE=' .env.local 2>/dev/null; then
|
||||||
|
echo " .env.local already has VIBN_* — skipping env append"
|
||||||
|
else
|
||||||
|
printf '\\n# Vibn workspace ${slug}\\n' >> .env.local
|
||||||
|
echo "${b64Env}" | base64 -d >> .env.local
|
||||||
|
echo " appended VIBN_* to .env.local"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f .gitignore ] && ! grep -q '^.env.local$' .gitignore 2>/dev/null; then
|
||||||
|
echo '.env.local' >> .gitignore
|
||||||
|
echo " added .env.local to .gitignore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Vibn workspace '${slug}' is wired up."
|
||||||
|
echo "Restart Cursor to pick up the new MCP server."
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCursorRule(opts: {
|
||||||
|
slug: string;
|
||||||
|
giteaOrg: string;
|
||||||
|
coolifyProjectUuid: string;
|
||||||
|
appBase: string;
|
||||||
|
}): string {
|
||||||
|
const { slug, giteaOrg, coolifyProjectUuid, appBase } = opts;
|
||||||
|
return `---
|
||||||
|
description: Vibn workspace "${slug}" — one-shot setup for AI agents
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vibn workspace: ${slug}
|
||||||
|
|
||||||
|
You are acting on behalf of the Vibn workspace **${slug}**. All AI
|
||||||
|
integration with Gitea and Coolify happens through the Vibn REST API,
|
||||||
|
which enforces tenancy for you.
|
||||||
|
|
||||||
|
## How to act
|
||||||
|
|
||||||
|
1. Before any git or deploy work, call:
|
||||||
|
\`GET ${appBase}/api/workspaces/${slug}/gitea-credentials\`
|
||||||
|
with \`Authorization: Bearer $VIBN_API_KEY\` to get a
|
||||||
|
workspace-scoped bot username, PAT, and clone URL template.
|
||||||
|
|
||||||
|
2. Use the returned \`cloneUrlTemplate\` (with \`{{repo}}\` substituted)
|
||||||
|
as the git remote. Never pass the root admin token to git.
|
||||||
|
|
||||||
|
3. For deploys, logs, env vars, call the workspace-scoped Coolify
|
||||||
|
endpoints under \`${appBase}/api/workspaces/${slug}/apps/...\`.
|
||||||
|
Any cross-tenant attempt is rejected with HTTP 403.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Gitea org: \`${giteaOrg}\`
|
||||||
|
- Coolify project uuid: \`${coolifyProjectUuid}\`
|
||||||
|
- API base: \`${appBase}\`
|
||||||
|
|
||||||
|
## Useful endpoints
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
|-------:|----------------------------------------------------------------|
|
||||||
|
| GET | /api/workspaces/${slug} |
|
||||||
|
| GET | /api/workspaces/${slug}/gitea-credentials |
|
||||||
|
| GET | /api/workspaces/${slug}/apps |
|
||||||
|
| GET | /api/workspaces/${slug}/apps/{uuid} |
|
||||||
|
| POST | /api/workspaces/${slug}/apps/{uuid}/deploy |
|
||||||
|
| GET | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||||
|
| PATCH | /api/workspaces/${slug}/apps/{uuid}/envs |
|
||||||
|
| DELETE | /api/workspaces/${slug}/apps/{uuid}/envs?key=FOO |
|
||||||
|
| POST | /api/mcp (JSON { tool, params } — see GET /api/mcp for list) |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never print or commit \`$VIBN_API_KEY\`.
|
||||||
|
- Prefer PRs over force-pushing \`main\`.
|
||||||
|
- If you see HTTP 403 on Coolify ops, you're trying to touch an app
|
||||||
|
outside this workspace — stop and ask the user.
|
||||||
|
- Re-run \`bootstrap.sh\` instead of hand-editing these files.
|
||||||
|
`;
|
||||||
|
}
|
||||||
158
app/api/workspaces/[slug]/databases/[uuid]/route.ts
Normal file
158
app/api/workspaces/[slug]/databases/[uuid]/route.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/databases/[uuid] — database details (incl. URLs)
|
||||||
|
* PATCH /api/workspaces/[slug]/databases/[uuid] — update fields
|
||||||
|
* DELETE /api/workspaces/[slug]/databases/[uuid]?confirm=<name>
|
||||||
|
* Volumes KEPT by default (data). Pass &delete_volumes=true to drop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getDatabaseInProject,
|
||||||
|
updateDatabase,
|
||||||
|
deleteDatabase,
|
||||||
|
projectUuidOf,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
uuid: db.uuid,
|
||||||
|
name: db.name,
|
||||||
|
type: db.type ?? null,
|
||||||
|
status: db.status,
|
||||||
|
isPublic: db.is_public ?? false,
|
||||||
|
publicPort: db.public_port ?? null,
|
||||||
|
internalUrl: db.internal_db_url ?? null,
|
||||||
|
externalUrl: db.external_db_url ?? null,
|
||||||
|
projectUuid: projectUuidOf(db),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set([
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'is_public',
|
||||||
|
'public_port',
|
||||||
|
'image',
|
||||||
|
'limits_memory',
|
||||||
|
'limits_cpus',
|
||||||
|
]);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(body)) {
|
||||||
|
if (allowed.has(k) && v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No updatable fields in body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDatabase(uuid, patch);
|
||||||
|
return NextResponse.json({ ok: true, uuid });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify update failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, uuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let db;
|
||||||
|
try {
|
||||||
|
db = await getDatabaseInProject(uuid, ws.coolify_project_uuid);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
return NextResponse.json({ error: 'Database not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const confirm = url.searchParams.get('confirm');
|
||||||
|
if (confirm !== db.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Confirmation required', hint: `Pass ?confirm=${db.name}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: preserve volumes (it's a database — user data lives there).
|
||||||
|
const deleteVolumes = url.searchParams.get('delete_volumes') === 'true';
|
||||||
|
const deleteConfigurations = url.searchParams.get('delete_configurations') !== 'false';
|
||||||
|
const deleteConnectedNetworks = url.searchParams.get('delete_connected_networks') !== 'false';
|
||||||
|
const dockerCleanup = url.searchParams.get('docker_cleanup') !== 'false';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteDatabase(uuid, {
|
||||||
|
deleteConfigurations,
|
||||||
|
deleteVolumes,
|
||||||
|
deleteConnectedNetworks,
|
||||||
|
dockerCleanup,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
deleted: { uuid, name: db.name, volumesKept: !deleteVolumes },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify delete failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
161
app/api/workspaces/[slug]/databases/route.ts
Normal file
161
app/api/workspaces/[slug]/databases/route.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/databases — list databases in this workspace
|
||||||
|
* POST /api/workspaces/[slug]/databases — provision a new database
|
||||||
|
*
|
||||||
|
* Supported `type` values (all that Coolify v4 can deploy):
|
||||||
|
* postgresql | mysql | mariadb | mongodb | redis | keydb | dragonfly | clickhouse
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* type: "postgresql",
|
||||||
|
* name?: "my-db",
|
||||||
|
* isPublic?: true, // expose a host port for remote clients
|
||||||
|
* publicPort?: 5433,
|
||||||
|
* image?: "postgres:16",
|
||||||
|
* credentials?: { ... } // type-specific (e.g. postgres_user)
|
||||||
|
* limits?: { memory?: "1G", cpus?: "1" },
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Tenancy: every returned record is filtered to the workspace's own
|
||||||
|
* Coolify project UUID.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
listDatabasesInProject,
|
||||||
|
createDatabase,
|
||||||
|
getDatabase,
|
||||||
|
projectUuidOf,
|
||||||
|
type CoolifyDatabaseType,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
import { slugify } from '@/lib/naming';
|
||||||
|
|
||||||
|
const SUPPORTED_TYPES: readonly CoolifyDatabaseType[] = [
|
||||||
|
'postgresql',
|
||||||
|
'mysql',
|
||||||
|
'mariadb',
|
||||||
|
'mongodb',
|
||||||
|
'redis',
|
||||||
|
'keydb',
|
||||||
|
'dragonfly',
|
||||||
|
'clickhouse',
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Workspace has no Coolify project yet', databases: [] },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbs = await listDatabasesInProject(ws.coolify_project_uuid);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: ws.slug, coolifyProjectUuid: ws.coolify_project_uuid },
|
||||||
|
databases: dbs.map(d => ({
|
||||||
|
uuid: d.uuid,
|
||||||
|
name: d.name,
|
||||||
|
type: d.type ?? null,
|
||||||
|
status: d.status,
|
||||||
|
isPublic: d.is_public ?? false,
|
||||||
|
publicPort: d.public_port ?? null,
|
||||||
|
projectUuid: projectUuidOf(d),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
publicPort?: number;
|
||||||
|
image?: string;
|
||||||
|
credentials?: Record<string, unknown>;
|
||||||
|
limits?: { memory?: string; cpus?: string };
|
||||||
|
instantDeploy?: boolean;
|
||||||
|
};
|
||||||
|
let body: Body = {};
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as Body;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = body.type as CoolifyDatabaseType | undefined;
|
||||||
|
if (!type || !SUPPORTED_TYPES.includes(type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `\`type\` must be one of: ${SUPPORTED_TYPES.join(', ')}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = slugify(body.name ?? `${type}-${Date.now().toString(36)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createDatabase({
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description: body.description,
|
||||||
|
projectUuid: ws.coolify_project_uuid,
|
||||||
|
serverUuid: ws.coolify_server_uuid ?? undefined,
|
||||||
|
environmentName: ws.coolify_environment_name,
|
||||||
|
destinationUuid: ws.coolify_destination_uuid ?? undefined,
|
||||||
|
isPublic: body.isPublic,
|
||||||
|
publicPort: body.publicPort,
|
||||||
|
image: body.image,
|
||||||
|
credentials: body.credentials,
|
||||||
|
limits: body.limits,
|
||||||
|
instantDeploy: body.instantDeploy ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = await getDatabase(created.uuid);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
uuid: db.uuid,
|
||||||
|
name: db.name,
|
||||||
|
type: db.type ?? type,
|
||||||
|
status: db.status,
|
||||||
|
isPublic: db.is_public ?? false,
|
||||||
|
publicPort: db.public_port ?? null,
|
||||||
|
internalUrl: db.internal_db_url ?? null,
|
||||||
|
externalUrl: db.external_db_url ?? null,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify create failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/deployments/[deploymentUuid]/logs
|
||||||
|
*
|
||||||
|
* Raw deployment logs. We can't tell from a deployment UUID alone
|
||||||
|
* which project it belongs to, so we require `?appUuid=...` and
|
||||||
|
* verify that app belongs to the workspace first. This keeps the
|
||||||
|
* tenant boundary intact even though Coolify's log endpoint is
|
||||||
|
* global.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
getApplicationInProject,
|
||||||
|
getDeploymentLogs,
|
||||||
|
TenantError,
|
||||||
|
} from '@/lib/coolify';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; deploymentUuid: string }> }
|
||||||
|
) {
|
||||||
|
const { slug, deploymentUuid } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
if (!ws.coolify_project_uuid) {
|
||||||
|
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUuid = new URL(request.url).searchParams.get('appUuid');
|
||||||
|
if (!appUuid) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Query param "appUuid" is required for tenant enforcement' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getApplicationInProject(appUuid, ws.coolify_project_uuid);
|
||||||
|
const logs = await getDeploymentLogs(deploymentUuid);
|
||||||
|
return NextResponse.json(logs);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TenantError) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Coolify request failed', details: String(err) },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
77
app/api/workspaces/[slug]/domains/[domain]/attach/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/workspaces/[slug]/domains/[domain]/attach
|
||||||
|
*
|
||||||
|
* Wires a registered domain up to a Coolify app (or arbitrary IP/CNAME)
|
||||||
|
* in one call. Idempotent — safe to retry.
|
||||||
|
*
|
||||||
|
* The heavy lifting lives in `lib/domain-attach.ts` so the MCP tool of the
|
||||||
|
* same name executes the same workflow.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* appUuid?: string,
|
||||||
|
* ip?: string,
|
||||||
|
* cname?: string,
|
||||||
|
* subdomains?: string[] // default ["@", "www"]
|
||||||
|
* updateRegistrarNs?: boolean // default true
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { attachDomain, AttachError, type AttachInput } from '@/lib/domain-attach';
|
||||||
|
import { getDomainForWorkspace } from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||||
|
) {
|
||||||
|
const { slug, domain: domainRaw } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const ws = principal.workspace;
|
||||||
|
const apex = decodeURIComponent(domainRaw).toLowerCase().trim();
|
||||||
|
|
||||||
|
const row = await getDomainForWorkspace(ws.id, apex);
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: AttachInput = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
// empty body is acceptable for a no-op attach check
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await attachDomain(ws, row, body);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
domain: {
|
||||||
|
id: result.domain.id,
|
||||||
|
domain: result.domain.domain,
|
||||||
|
dnsProvider: result.domain.dns_provider,
|
||||||
|
dnsZoneId: result.domain.dns_zone_id,
|
||||||
|
dnsNameservers: result.domain.dns_nameservers,
|
||||||
|
},
|
||||||
|
zone: result.zone,
|
||||||
|
records: result.records,
|
||||||
|
registrarNsUpdate: result.registrarNsUpdate,
|
||||||
|
coolifyUpdate: result.coolifyUpdate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AttachError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message, tag: err.tag, ...(err.extra ?? {}) },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error('[domains.attach] unexpected', err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Attach failed', details: err instanceof Error ? err.message : String(err) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
63
app/api/workspaces/[slug]/domains/[domain]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/workspaces/[slug]/domains/[domain]
|
||||||
|
*
|
||||||
|
* Returns the full domain record (sans encrypted registrar password) plus
|
||||||
|
* recent lifecycle events. Used by the UI and agents to check status after
|
||||||
|
* a register call.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import { query } from '@/lib/db-postgres';
|
||||||
|
import { getDomainForWorkspace } from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string; domain: string }> },
|
||||||
|
) {
|
||||||
|
const { slug, domain } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const row = await getDomainForWorkspace(principal.workspace.id, decodeURIComponent(domain));
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Domain not found in this workspace' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await query<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT id, type, payload, created_at
|
||||||
|
FROM vibn_domain_events
|
||||||
|
WHERE domain_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20`,
|
||||||
|
[row.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: row.id,
|
||||||
|
domain: row.domain,
|
||||||
|
tld: row.tld,
|
||||||
|
status: row.status,
|
||||||
|
registrar: row.registrar,
|
||||||
|
registrarOrderId: row.registrar_order_id,
|
||||||
|
registrarUsername: row.registrar_username,
|
||||||
|
periodYears: row.period_years,
|
||||||
|
whoisPrivacy: row.whois_privacy,
|
||||||
|
autoRenew: row.auto_renew,
|
||||||
|
registeredAt: row.registered_at,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
dnsProvider: row.dns_provider,
|
||||||
|
dnsZoneId: row.dns_zone_id,
|
||||||
|
dnsNameservers: row.dns_nameservers,
|
||||||
|
pricePaidCents: row.price_paid_cents,
|
||||||
|
priceCurrency: row.price_currency,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
}
|
||||||
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
238
app/api/workspaces/[slug]/domains/route.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Workspace-owned domains.
|
||||||
|
*
|
||||||
|
* GET /api/workspaces/[slug]/domains — list domains owned by the workspace
|
||||||
|
* POST /api/workspaces/[slug]/domains — register a domain through OpenSRS
|
||||||
|
*
|
||||||
|
* POST body:
|
||||||
|
* {
|
||||||
|
* domain: "example.com",
|
||||||
|
* period?: 1,
|
||||||
|
* whoisPrivacy?: true,
|
||||||
|
* contact: {
|
||||||
|
* first_name, last_name, org_name?,
|
||||||
|
* address1, address2?, city, state, country, postal_code,
|
||||||
|
* phone, fax?, email
|
||||||
|
* },
|
||||||
|
* nameservers?: string[],
|
||||||
|
* ca?: { cprCategory, legalType } // required for .ca
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Safety rails:
|
||||||
|
* - `OPENSRS_MODE=test` is strongly recommended until we've verified live
|
||||||
|
* registration end-to-end. The handler reads the current mode from env
|
||||||
|
* and echoes it in the response so agents can tell.
|
||||||
|
* - We guard against duplicate POSTs by reusing an existing `pending` row
|
||||||
|
* for the same (workspace, domain) pair — caller can retry safely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||||
|
import {
|
||||||
|
domainTld,
|
||||||
|
registerDomain,
|
||||||
|
OpenSrsError,
|
||||||
|
minPeriodFor,
|
||||||
|
type RegistrationContact,
|
||||||
|
} from '@/lib/opensrs';
|
||||||
|
import {
|
||||||
|
createDomainIntent,
|
||||||
|
getDomainForWorkspace,
|
||||||
|
listDomainsForWorkspace,
|
||||||
|
markDomainFailed,
|
||||||
|
markDomainRegistered,
|
||||||
|
recordDomainEvent,
|
||||||
|
recordLedgerEntry,
|
||||||
|
} from '@/lib/domains';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
const rows = await listDomainsForWorkspace(principal.workspace.id);
|
||||||
|
return NextResponse.json({
|
||||||
|
workspace: { slug: principal.workspace.slug },
|
||||||
|
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||||
|
domains: rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
domain: r.domain,
|
||||||
|
tld: r.tld,
|
||||||
|
status: r.status,
|
||||||
|
registrar: r.registrar,
|
||||||
|
periodYears: r.period_years,
|
||||||
|
whoisPrivacy: r.whois_privacy,
|
||||||
|
autoRenew: r.auto_renew,
|
||||||
|
registeredAt: r.registered_at,
|
||||||
|
expiresAt: r.expires_at,
|
||||||
|
dnsProvider: r.dns_provider,
|
||||||
|
dnsNameservers: r.dns_nameservers,
|
||||||
|
pricePaidCents: r.price_paid_cents,
|
||||||
|
priceCurrency: r.price_currency,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostBody {
|
||||||
|
domain?: string;
|
||||||
|
period?: number;
|
||||||
|
whoisPrivacy?: boolean;
|
||||||
|
contact?: RegistrationContact;
|
||||||
|
nameservers?: string[];
|
||||||
|
ca?: { cprCategory: string; legalType: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
let body: PostBody = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (body.domain ?? '').toString().trim().toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '').replace(/\/+$/, '');
|
||||||
|
if (!raw || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(raw)) {
|
||||||
|
return NextResponse.json({ error: '`domain` is required and must be a valid hostname' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const contactValidation = validateContact(body.contact);
|
||||||
|
if (contactValidation) return contactValidation;
|
||||||
|
|
||||||
|
const tld = domainTld(raw);
|
||||||
|
if (tld === 'ca' && !body.ca) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '.ca registration requires { ca: { cprCategory, legalType } }' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = minPeriodFor(tld, typeof body.period === 'number' ? body.period : 1);
|
||||||
|
|
||||||
|
// Reuse an existing pending intent to keep POSTs idempotent.
|
||||||
|
let intent = await getDomainForWorkspace(principal.workspace.id, raw);
|
||||||
|
if (intent && intent.status === 'active') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Domain ${raw} is already registered in this workspace`, domainId: intent.id },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!intent) {
|
||||||
|
intent = await createDomainIntent({
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
domain: raw,
|
||||||
|
createdBy: principal.userId,
|
||||||
|
periodYears: period,
|
||||||
|
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordDomainEvent({
|
||||||
|
domainId: intent.id,
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
type: 'register.attempt',
|
||||||
|
payload: { period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await registerDomain({
|
||||||
|
domain: raw,
|
||||||
|
period,
|
||||||
|
contact: body.contact as RegistrationContact,
|
||||||
|
nameservers: body.nameservers,
|
||||||
|
whoisPrivacy: body.whoisPrivacy ?? true,
|
||||||
|
ca: body.ca,
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceCents: number | null = null; // registrar price is captured at search time; later we'll pull the real reseller debit from get_balance_changes
|
||||||
|
const currency = process.env.OPENSRS_CURRENCY ?? 'CAD';
|
||||||
|
|
||||||
|
const updated = await markDomainRegistered({
|
||||||
|
domainId: intent.id,
|
||||||
|
registrarOrderId: result.orderId,
|
||||||
|
registrarUsername: result.regUsername,
|
||||||
|
registrarPassword: result.regPassword,
|
||||||
|
periodYears: period,
|
||||||
|
pricePaidCents: priceCents,
|
||||||
|
priceCurrency: currency,
|
||||||
|
registeredAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + period * 365 * 24 * 60 * 60 * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (priceCents) {
|
||||||
|
await recordLedgerEntry({
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
kind: 'debit',
|
||||||
|
amountCents: priceCents,
|
||||||
|
currency,
|
||||||
|
refType: 'domain.register',
|
||||||
|
refId: intent.id,
|
||||||
|
note: `Register ${raw} (${period}y)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordDomainEvent({
|
||||||
|
domainId: intent.id,
|
||||||
|
workspaceId: principal.workspace.id,
|
||||||
|
type: 'register.success',
|
||||||
|
payload: { orderId: result.orderId, period, mode: process.env.OPENSRS_MODE ?? 'test' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
mode: process.env.OPENSRS_MODE ?? 'test',
|
||||||
|
domain: {
|
||||||
|
id: updated.id,
|
||||||
|
domain: updated.domain,
|
||||||
|
tld: updated.tld,
|
||||||
|
status: updated.status,
|
||||||
|
periodYears: updated.period_years,
|
||||||
|
registeredAt: updated.registered_at,
|
||||||
|
expiresAt: updated.expires_at,
|
||||||
|
registrarOrderId: updated.registrar_order_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
await markDomainFailed(intent.id, message);
|
||||||
|
if (err instanceof OpenSrsError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Registration failed', registrarCode: err.code, details: err.message },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Registration failed', details: message },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContact(c?: RegistrationContact): NextResponse | null {
|
||||||
|
if (!c) return NextResponse.json({ error: '`contact` is required' }, { status: 400 });
|
||||||
|
const required: (keyof RegistrationContact)[] = [
|
||||||
|
'first_name', 'last_name', 'address1', 'city', 'state', 'country', 'postal_code', 'phone', 'email',
|
||||||
|
];
|
||||||
|
for (const k of required) {
|
||||||
|
if (!c[k] || typeof c[k] !== 'string' || !(c[k] as string).trim()) {
|
||||||
|
return NextResponse.json({ error: `contact.${k} is required` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!/^[A-Z]{2}$/.test(c.country)) {
|
||||||
|
return NextResponse.json({ error: 'contact.country must be an ISO 3166-1 alpha-2 code' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!/@/.test(c.email)) {
|
||||||
|
return NextResponse.json({ error: 'contact.email must be a valid email' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user