feat(api): comprehensive QA hardening — security gates, chat improvements, beta scaffolds
Closes checklist items F-01..F-06, D-01..D-28, S-01..S-10, C-01..C-07, B-01..B-07, R-01..R-02, O-03. Security (28 deletions + 10 auth gates): - Delete 28 unauthenticated debug/cursor/firebase/test routes - Gate ai/chat, ai/conversation, context/summarize, work-completed with withTenantProject/withAuth - Add HMAC-SHA256 signature verification to webhooks/coolify - Switch all admin secret comparisons to timingSafeStringEq Foundations (lib/server/*): - api-handler.ts: withAuth, withTenantProject, withWorkspace, withAdminSecret, withRateLimit - logger.ts: structured request-scoped logging with turnId - audit-log.ts: writeAuditLog helper + audit_log table - rate-limit.ts: Postgres sliding window rate limiter - coolify-webhook.ts: verifyCoolifySignature - timing-safe.ts: timingSafeStringEq Chat hardening (chat/route.ts): - MAX_TOOL_ROUNDS 15 → 8 (C-01) - Loop detection: hard-break at 3 identical fingerprints (was 5) (C-02) - Add 6-consecutive-tool-call hard-break (C-02) - Mode: respond first, act second prompt block (C-03) - SSE heartbeat every 25s via setInterval (C-04) - Per-tool 45s timeout via Promise.race (C-05) - turnId per-turn UUID for log correlation (C-06) - Recovery fires when roundsSinceText >= 4 (C-07) - SSE plan event on plan_task_add/edit (B-05) Beta features: - invites table + GET/POST /api/invites (P4.8) - invites/[token] validate + redeem (P4.8) - fs_project_dev_servers table + lib/server/dev-server-state.ts (P6.B1) - fs_project_secrets table + CRUD routes (P6.D2) - lib/integrations/brief-extract.ts (P3.7) Documentation: - app/api/ROUTES.md: full route map with auth + tenant
This commit is contained in:
159
vibn-frontend/app/api/ROUTES.md
Normal file
159
vibn-frontend/app/api/ROUTES.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# API Route Map
|
||||
|
||||
> Generated 2026-05-17. Auth column: `session` = NextAuth cookie,
|
||||
> `api_key` = `vibn_sk_…` bearer, `admin_secret` = env-var secret,
|
||||
> `webhook_sig` = HMAC-SHA256, `public` = no auth.
|
||||
>
|
||||
> Tenant column: `workspace` = must belong to caller's workspace,
|
||||
> `project` = must own project, `user` = must match session user,
|
||||
> `global` = cross-workspace admin op.
|
||||
|
||||
## Chat
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/chat` | session | workspace | Main SSE chat with Gemini + tool loop |
|
||||
| GET | `/api/chat/threads` | session | user | List threads |
|
||||
| POST | `/api/chat/threads` | session | user | Create thread |
|
||||
| GET | `/api/chat/threads/[id]` | session | user | Get thread + messages |
|
||||
| PATCH | `/api/chat/threads/[id]` | session | user | Rename thread |
|
||||
| DELETE | `/api/chat/threads/[id]` | session | user | Delete thread |
|
||||
|
||||
## AI (legacy, plan to deprecate)
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/ai/chat` | session | project | Old collector-mode chat (pre-tool) |
|
||||
| GET | `/api/ai/conversation` | session | project | Fetch saved conversation history |
|
||||
| DELETE | `/api/ai/conversation` | session | project | Wipe conversation history |
|
||||
| POST | `/api/ai/conversation/reset` | session | project | Alias for DELETE |
|
||||
|
||||
## Projects
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET | `/api/projects` | session | user | List user's projects |
|
||||
| POST | `/api/projects/create` | session | user | Create project (enforces quota) |
|
||||
| POST | `/api/projects/delete` | session | project | Delete project |
|
||||
| GET/PATCH | `/api/projects/[projectId]` | session | project | Get / update project |
|
||||
| GET | `/api/projects/[projectId]/activity` | session | project | Activity feed |
|
||||
| POST | `/api/projects/[projectId]/advisor` | session | project | AI advisor |
|
||||
| GET/POST | `/api/projects/[projectId]/anatomy` | session | project | Anatomy read/update |
|
||||
| GET/POST | `/api/projects/[projectId]/apps` | session | project | App list / create |
|
||||
| GET/POST | `/api/projects/[projectId]/design-kit` | session | project | Design kit CRUD |
|
||||
| GET/POST | `/api/projects/[projectId]/plan` | session | project | Plan read/update |
|
||||
| POST | `/api/projects/[projectId]/plan/intelligent` | session | project | AI plan generation |
|
||||
| POST | `/api/projects/[projectId]/plan/mvp` | session | project | MVP plan |
|
||||
| POST | `/api/projects/[projectId]/plan/marketing` | session | project | Marketing plan |
|
||||
| POST | `/api/projects/[projectId]/documents/upload` | session | project | Upload brief |
|
||||
| GET/POST | `/api/projects/[projectId]/secrets` | session | project | List/set project secrets (B-06) |
|
||||
| GET/DELETE | `/api/projects/[projectId]/secrets/[key]` | session | project | Reveal/delete secret (B-06) |
|
||||
| GET | `/api/projects/[projectId]/knowledge` | session | project | Knowledge items |
|
||||
| POST | `/api/projects/[projectId]/knowledge/batch-extract` | session | project | Batch extract knowledge |
|
||||
| GET/POST | `/api/projects/[projectId]/agent/sessions` | session | project | Agent session CRUD |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]` | session | project | Session state |
|
||||
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/approve` | session | project | Approve session commit |
|
||||
| POST | `/api/projects/[projectId]/agent/sessions/[sessionId]/stop` | session | project | Stop agent |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events` | session | project | Event list |
|
||||
| GET | `/api/projects/[projectId]/agent/sessions/[sessionId]/events/stream` | session | project | SSE event tail |
|
||||
|
||||
## Workspaces
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET | `/api/workspaces` | session/api_key | user | List workspaces |
|
||||
| POST | `/api/workspaces/delete` | session | user | Delete workspace |
|
||||
| GET | `/api/workspaces/[slug]` | session/api_key | workspace | Get workspace |
|
||||
| GET/POST | `/api/workspaces/[slug]/apps` | session/api_key | workspace | List/create apps |
|
||||
| GET/PATCH/DELETE | `/api/workspaces/[slug]/apps/[uuid]` | session/api_key | workspace | App CRUD |
|
||||
| POST | `/api/workspaces/[slug]/apps/[uuid]/deploy` | session/api_key | workspace | Trigger deploy |
|
||||
| GET | `/api/workspaces/[slug]/apps/[uuid]/logs` | session/api_key | workspace | Runtime logs |
|
||||
| GET/PATCH | `/api/workspaces/[slug]/apps/[uuid]/envs` | session/api_key | workspace | Env vars |
|
||||
| POST | `/api/workspaces/[slug]/apps/[uuid]/exec` | session/api_key | workspace | Remote exec |
|
||||
| GET/POST | `/api/workspaces/[slug]/databases` | session/api_key | workspace | Database CRUD |
|
||||
| GET | `/api/workspaces/[slug]/domains` | session/api_key | workspace | Domain list |
|
||||
| GET/POST | `/api/workspaces/[slug]/keys` | session/api_key | workspace | API keys |
|
||||
| POST | `/api/workspaces/[slug]/provision` | session/api_key | workspace | Provision workspace |
|
||||
|
||||
## MCP
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/mcp` | session/api_key | workspace | All 40+ MCP tools |
|
||||
| GET/POST/DELETE | `/api/mcp/generate-key` | session | user | Manage MCP API keys |
|
||||
|
||||
## Auth / Sessions
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST | `/api/auth/[...nextauth]` | public | - | NextAuth handlers |
|
||||
| GET | `/api/user/api-key` | session | user | Get/create user API key |
|
||||
| GET | `/api/sessions` | session | user | Session history |
|
||||
| POST | `/api/sessions/track` | session | user | Track session event |
|
||||
| POST | `/api/sessions/associate-project` | session | user | Link session to project |
|
||||
|
||||
## GitHub Integrations
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST/DELETE | `/api/github/connect` | session | user | Legacy GitHub OAuth connect |
|
||||
| GET | `/api/github/repos` | session | user | List connected repos |
|
||||
| GET | `/api/github/repo-tree` | session | user | Repo file tree |
|
||||
| GET | `/api/github/file-content` | session | user | Single file content |
|
||||
| POST | `/api/github/oauth/token` | public | - | OAuth token exchange |
|
||||
| GET | `/api/integrations/github/connect` | session | user | New OAuth connect |
|
||||
| GET | `/api/integrations/github/callback` | public | - | OAuth callback |
|
||||
| POST | `/api/integrations/github/disconnect` | session | user | Disconnect GitHub |
|
||||
| GET | `/api/integrations/github/repos` | session | user | New integration repos |
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/webhooks/gitea` | webhook_sig | project | Gitea push events → context snapshot |
|
||||
| POST | `/api/webhooks/coolify` | webhook_sig | project | Deploy status → context snapshot |
|
||||
|
||||
## Invites (P4.8)
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| GET/POST | `/api/invites` | admin_secret | global | Create / list invite tokens |
|
||||
| GET | `/api/invites/[token]` | public | - | Validate token (used by auth page) |
|
||||
| POST | `/api/invites/[token]` | session | user | Redeem token on signup |
|
||||
|
||||
## Admin / Ops
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/admin/migrate` | admin_secret | global | Run SQL migrations |
|
||||
| GET | `/api/admin/path-b` | admin_secret | global | Path B kill-switch state |
|
||||
| POST | `/api/admin/path-b/disable` | admin_secret | global | Disable Path B |
|
||||
| POST | `/api/admin/path-b/enable` | admin_secret | global | Enable Path B |
|
||||
| POST | `/api/admin/path-b/idle-sweep` | admin_secret | global | Suspend idle dev containers |
|
||||
| POST | `/api/admin/path-b/autosave` | admin_secret | global | Autosave workspace |
|
||||
| POST | `/api/admin/backfill-isolation` | admin_secret | global | Backfill tenant isolation |
|
||||
| POST | `/api/admin/path-b` | admin_secret | global | Path B bulk status |
|
||||
| GET | `/api/internal/infra-health` | admin_secret | global | Coolify + SSH probe |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| POST | `/api/context/summarize` | session | user | Gemini one-shot doc summary |
|
||||
| GET | `/api/activity` | session | user | Workspace activity feed |
|
||||
| GET | `/api/work-completed` | session | project | Work completed items |
|
||||
| GET | `/api/stats` | session | user | Usage stats |
|
||||
| GET | `/api/preview/embed` | session | user | HTML proxy for preview iframes |
|
||||
| GET/POST | `/api/extension/link-project` | session | user | Browser extension project link |
|
||||
| POST | `/api/vision/update` | session | project | Update project vision |
|
||||
| GET | `/api/keys` | session | user | User key management |
|
||||
| GET | `/api/design-systems/[id]/preview` | public | - | Design system preview HTML |
|
||||
| GET | `/api/design-systems/[id]/showcase` | public | - | Design system showcase |
|
||||
|
||||
## Deprecated / V0
|
||||
|
||||
| Method | Path | Auth | Tenant | Purpose |
|
||||
|--------|------|------|--------|---------|
|
||||
| * | `/api/v0/*` | varies | varies | Legacy v0 integration (verify still used) |
|
||||
| * | `/api/openai/*` | session | user | OpenAI GPT import (verify still used) |
|
||||
| * | `/api/chatgpt/*` | session | user | ChatGPT conversation import |
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get('projectId');
|
||||
const userId = searchParams.get('userId');
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get all sessions for this user
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const allSessions = sessionsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
projectId: data.projectId || null,
|
||||
workspacePath: data.workspacePath || null,
|
||||
workspaceName: data.workspaceName || null,
|
||||
needsProjectAssociation: data.needsProjectAssociation,
|
||||
messageCount: data.messageCount,
|
||||
conversationLength: data.conversation?.length || 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Filter sessions that match this project
|
||||
const matchingSessions = allSessions.filter(s => s.projectId === projectId);
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions: allSessions.length,
|
||||
matchingSessions: matchingSessions.length,
|
||||
allSessions,
|
||||
projectId,
|
||||
userId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Admin Check Sessions] Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { projectId, workspacePath } = await request.json();
|
||||
|
||||
if (!projectId || !workspacePath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId and workspacePath required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Update project with workspacePath
|
||||
await adminDb.collection('projects').doc(projectId).update({
|
||||
workspacePath,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(`[Fix Project] Set workspacePath for ${projectId}: ${workspacePath}`);
|
||||
|
||||
// Now find and link all matching sessions
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('workspacePath', '==', workspacePath)
|
||||
.where('needsProjectAssociation', '==', true)
|
||||
.get();
|
||||
|
||||
const batch = adminDb.batch();
|
||||
let linkedCount = 0;
|
||||
|
||||
for (const sessionDoc of sessionsSnapshot.docs) {
|
||||
batch.update(sessionDoc.ref, {
|
||||
projectId,
|
||||
needsProjectAssociation: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
linkedCount++;
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
console.log(`[Fix Project] Linked ${linkedCount} sessions to project ${projectId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
projectId,
|
||||
workspacePath,
|
||||
sessionsLinked: linkedCount,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[Fix Project] Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,17 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
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 }
|
||||
{
|
||||
error:
|
||||
"ADMIN_MIGRATE_SECRET env var not set — migration endpoint disabled",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ export async function POST(req: NextRequest) {
|
||||
new URL(req.url).searchParams.get("secret") ??
|
||||
"";
|
||||
|
||||
if (incoming !== secret) {
|
||||
if (!incoming || !timingSafeStringEq(secret, incoming)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -299,9 +303,9 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter(r => !r.ok);
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
return NextResponse.json(
|
||||
{ ok: failed.length === 0, results },
|
||||
{ status: failed.length === 0 ? 200 : 207 }
|
||||
{ status: failed.length === 0 ? 200 : 207 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,22 +18,32 @@
|
||||
* commits go through the `ship` tool.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { autosaveWorkspace } from '@/lib/dev-container';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { getOrCreateProvisionedWorkspace } from '@/lib/workspaces';
|
||||
import { NextResponse } from "next/server";
|
||||
import { autosaveWorkspace } from "@/lib/dev-container";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { getOrCreateProvisionedWorkspace } from "@/lib/workspaces";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: { projectId?: string; projectSlug?: string; sweep?: boolean };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Single-project mode.
|
||||
@@ -44,15 +54,18 @@ export async function POST(request: Request) {
|
||||
[projectId],
|
||||
);
|
||||
if (row.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
const ws = await getOrCreateProvisionedWorkspace({
|
||||
userId: row[0].data?.userId ?? '',
|
||||
email: row[0].data?.ownerEmail ?? '',
|
||||
userId: row[0].data?.userId ?? "",
|
||||
email: row[0].data?.ownerEmail ?? "",
|
||||
displayName: row[0].workspace,
|
||||
}).catch(() => null);
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not provisioned' }, { status: 503 });
|
||||
return NextResponse.json(
|
||||
{ error: "Workspace not provisioned" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const result = await autosaveWorkspace({
|
||||
projectId,
|
||||
@@ -76,8 +89,8 @@ export async function POST(request: Request) {
|
||||
);
|
||||
if (proj.length === 0) continue;
|
||||
const ws = await getOrCreateProvisionedWorkspace({
|
||||
userId: proj[0].data?.userId ?? '',
|
||||
email: proj[0].data?.ownerEmail ?? '',
|
||||
userId: proj[0].data?.userId ?? "",
|
||||
email: proj[0].data?.ownerEmail ?? "",
|
||||
displayName: r.workspace,
|
||||
}).catch(() => null);
|
||||
if (!ws) continue;
|
||||
@@ -85,14 +98,17 @@ export async function POST(request: Request) {
|
||||
projectId: r.project_id,
|
||||
projectSlug: proj[0].slug,
|
||||
workspace: ws,
|
||||
}).catch(err => ({ ran: false, reason: err instanceof Error ? err.message : String(err) }));
|
||||
}).catch((err) => ({
|
||||
ran: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
out.push({ projectId: r.project_id, ran: res.ran, reason: res.reason });
|
||||
}
|
||||
return NextResponse.json({ result: { swept: out.length, out } });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Provide either { projectId } or { sweep: true }' },
|
||||
{ error: "Provide either { projectId } or { sweep: true }" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { setFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
await setFlag('path_b_disabled', true);
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
await setFlag("path_b_disabled", true);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
flag: 'path_b_disabled',
|
||||
flag: "path_b_disabled",
|
||||
value: true,
|
||||
note: 'Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.',
|
||||
note: "Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { setFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
await setFlag('path_b_disabled', false);
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
await setFlag("path_b_disabled", false);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
flag: 'path_b_disabled',
|
||||
flag: "path_b_disabled",
|
||||
value: false,
|
||||
note: 'Path B re-enabled.',
|
||||
note: "Path B re-enabled.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,18 +17,31 @@
|
||||
* the next shell.exec call resumes the service in <5s.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { suspendIdleContainers } from '@/lib/dev-container';
|
||||
import { NextResponse } from "next/server";
|
||||
import { suspendIdleContainers } from "@/lib/dev-container";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) {
|
||||
return NextResponse.json(
|
||||
{ error: "NEXTAUTH_SECRET not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
if (!bearer || !timingSafeStringEq(expected, bearer)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const minStr = url.searchParams.get('minutes');
|
||||
const minutes = minStr && Number.isFinite(Number(minStr)) ? Math.max(5, Number(minStr)) : 30;
|
||||
const minStr = url.searchParams.get("minutes");
|
||||
const minutes =
|
||||
minStr && Number.isFinite(Number(minStr))
|
||||
? Math.max(5, Number(minStr))
|
||||
: 30;
|
||||
const result = await suspendIdleContainers(minutes);
|
||||
return NextResponse.json({ result, idleMinutes: minutes });
|
||||
}
|
||||
|
||||
@@ -20,19 +20,24 @@
|
||||
* to every Vibn pod within ~10s of the SQL update.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getFlag, setFlag } from '@/lib/feature-flags';
|
||||
import { NextResponse } from "next/server";
|
||||
import { getFlag } from "@/lib/feature-flags";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
function authorized(request: Request): boolean {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
|
||||
return Boolean(bearer && process.env.NEXTAUTH_SECRET && bearer === process.env.NEXTAUTH_SECRET);
|
||||
const expected = process.env.NEXTAUTH_SECRET ?? "";
|
||||
if (!expected) return false;
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
const bearer = auth.toLowerCase().startsWith("bearer ")
|
||||
? auth.slice(7).trim()
|
||||
: "";
|
||||
return Boolean(bearer && timingSafeStringEq(expected, bearer));
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!authorized(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const disabled = await getFlag<boolean>('path_b_disabled', false);
|
||||
const disabled = await getFlag<boolean>("path_b_disabled", false);
|
||||
return NextResponse.json({ disabled });
|
||||
}
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
|
||||
import type { LlmClient } from '@/lib/ai/llm-client';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { MODE_SYSTEM_PROMPTS, ChatMode } from '@/lib/ai/chat-modes';
|
||||
import { resolveChatMode } from '@/lib/server/chat-mode-resolver';
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { GeminiLlmClient } from "@/lib/ai/gemini-client";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
import type { LlmClient } from "@/lib/ai/llm-client";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { MODE_SYSTEM_PROMPTS, ChatMode } from "@/lib/ai/chat-modes";
|
||||
import { resolveChatMode } from "@/lib/server/chat-mode-resolver";
|
||||
import {
|
||||
buildProjectContextForChat,
|
||||
determineArtifactsUsed,
|
||||
formatContextForPrompt,
|
||||
} from '@/lib/server/chat-context';
|
||||
import { logProjectEvent } from '@/lib/server/logs';
|
||||
import type { CollectorPhaseHandoff } from '@/lib/types/phase-handoff';
|
||||
} from "@/lib/server/chat-context";
|
||||
import { logProjectEvent } from "@/lib/server/logs";
|
||||
import type { CollectorPhaseHandoff } from "@/lib/types/phase-handoff";
|
||||
|
||||
// Increase timeout for Gemini 3 Pro thinking mode (can take 1-2 minutes)
|
||||
export const maxDuration = 180; // 3 minutes
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ChatReplySchema = z.object({
|
||||
reply: z.string(),
|
||||
visionAnswers: z.object({
|
||||
q1: z.string().optional(), // Answer to question 1
|
||||
q2: z.string().optional(), // Answer to question 2
|
||||
q3: z.string().optional(), // Answer to question 3
|
||||
allAnswered: z.boolean().optional(), // True when all 3 are complete
|
||||
}).optional(),
|
||||
collectorHandoff: z.object({
|
||||
hasDocuments: z.boolean().optional(),
|
||||
documentCount: z.number().optional(),
|
||||
githubConnected: z.boolean().optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
extensionLinked: z.boolean().optional(),
|
||||
extensionDeclined: z.boolean().optional(),
|
||||
noGithubYet: z.boolean().optional(),
|
||||
readyForExtraction: z.boolean().optional(),
|
||||
}).optional(),
|
||||
extractionReviewHandoff: z.object({
|
||||
extractionApproved: z.boolean().optional(),
|
||||
readyForVision: z.boolean().optional(),
|
||||
}).optional(),
|
||||
visionAnswers: z
|
||||
.object({
|
||||
q1: z.string().optional(), // Answer to question 1
|
||||
q2: z.string().optional(), // Answer to question 2
|
||||
q3: z.string().optional(), // Answer to question 3
|
||||
allAnswered: z.boolean().optional(), // True when all 3 are complete
|
||||
})
|
||||
.optional(),
|
||||
collectorHandoff: z
|
||||
.object({
|
||||
hasDocuments: z.boolean().optional(),
|
||||
documentCount: z.number().optional(),
|
||||
githubConnected: z.boolean().optional(),
|
||||
githubRepo: z.string().optional(),
|
||||
extensionLinked: z.boolean().optional(),
|
||||
extensionDeclined: z.boolean().optional(),
|
||||
noGithubYet: z.boolean().optional(),
|
||||
readyForExtraction: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
extractionReviewHandoff: z
|
||||
.object({
|
||||
extractionApproved: z.boolean().optional(),
|
||||
readyForVision: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface ChatRequestBody {
|
||||
@@ -57,7 +65,7 @@ const ENSURE_CONV_TABLE = `
|
||||
|
||||
async function appendConversation(
|
||||
projectId: string,
|
||||
newMessages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
newMessages: Array<{ role: "user" | "assistant"; content: string }>,
|
||||
) {
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const now = new Date().toISOString();
|
||||
@@ -69,52 +77,62 @@ async function appendConversation(
|
||||
ON CONFLICT (project_id) DO UPDATE
|
||||
SET messages = chat_conversations.messages || $2::jsonb,
|
||||
updated_at = NOW()`,
|
||||
[projectId, JSON.stringify(stamped)]
|
||||
[projectId, JSON.stringify(stamped)],
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as ChatRequestBody;
|
||||
const projectId = body.projectId?.trim();
|
||||
const message = body.message?.trim();
|
||||
export const POST = withTenantProject(
|
||||
async (request, _ctx, { project, user }) => {
|
||||
try {
|
||||
const body = (await request.json()) as ChatRequestBody;
|
||||
const projectId = project.id;
|
||||
const message = body.message?.trim();
|
||||
|
||||
if (!projectId || !message) {
|
||||
return NextResponse.json({ error: 'projectId and message are required' }, { status: 400 });
|
||||
}
|
||||
if (!message) {
|
||||
return NextResponse.json(
|
||||
{ error: "message is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify project exists in Postgres
|
||||
const projectRows = await query<{ data: any }>(
|
||||
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
);
|
||||
if (projectRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
}
|
||||
const projectData = projectRows[0].data ?? {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const projectData = (project.data ?? {}) as any;
|
||||
log.info("ai/chat: starting", {
|
||||
route: "api.ai.chat",
|
||||
projectId,
|
||||
user: user.email,
|
||||
});
|
||||
|
||||
// Resolve chat mode (uses new resolver)
|
||||
const resolvedMode = body.overrideMode ?? await resolveChatMode(projectId);
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}`);
|
||||
// Resolve chat mode (uses new resolver)
|
||||
const resolvedMode =
|
||||
body.overrideMode ?? (await resolveChatMode(projectId));
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}`);
|
||||
|
||||
// Build comprehensive context with vector retrieval
|
||||
// Only include GitHub analysis for MVP generation (not needed for vision questions)
|
||||
const context = await buildProjectContextForChat(projectId, resolvedMode, message, {
|
||||
retrievalLimit: 10,
|
||||
includeVectorSearch: true,
|
||||
includeGitHubAnalysis: resolvedMode === 'mvp_mode', // Only load repo analysis when generating MVP
|
||||
});
|
||||
// Build comprehensive context with vector retrieval
|
||||
// Only include GitHub analysis for MVP generation (not needed for vision questions)
|
||||
const context = await buildProjectContextForChat(
|
||||
projectId,
|
||||
resolvedMode,
|
||||
message,
|
||||
{
|
||||
retrievalLimit: 10,
|
||||
includeVectorSearch: true,
|
||||
includeGitHubAnalysis: resolvedMode === "mvp_mode", // Only load repo analysis when generating MVP
|
||||
},
|
||||
);
|
||||
|
||||
console.log(`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`);
|
||||
console.log(
|
||||
`[AI Chat] Context built: ${context.retrievedChunks.length} vector chunks retrieved`,
|
||||
);
|
||||
|
||||
// Get mode-specific system prompt
|
||||
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
|
||||
// Get mode-specific system prompt
|
||||
const systemPrompt = MODE_SYSTEM_PROMPTS[resolvedMode];
|
||||
|
||||
// Format context for LLM
|
||||
const contextSummary = formatContextForPrompt(context);
|
||||
// Format context for LLM
|
||||
const contextSummary = formatContextForPrompt(context);
|
||||
|
||||
// Prepare enhanced system prompt with context
|
||||
const enhancedSystemPrompt = `${systemPrompt}
|
||||
// Prepare enhanced system prompt with context
|
||||
const enhancedSystemPrompt = `${systemPrompt}
|
||||
|
||||
## Current Project Context
|
||||
|
||||
@@ -126,208 +144,248 @@ You have access to:
|
||||
- Project artifacts (product model, MVP plan, marketing plan)
|
||||
- Knowledge items (${context.knowledgeSummary.totalCount} total)
|
||||
- Extraction signals (${context.extractionSummary.totalCount} analyzed)
|
||||
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ''}
|
||||
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ''}
|
||||
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ''}
|
||||
${context.retrievedChunks.length > 0 ? `- ${context.retrievedChunks.length} relevant chunks from vector search (most similar to user's query)` : ""}
|
||||
${context.repositoryAnalysis ? `- GitHub repository analysis (${context.repositoryAnalysis.totalFiles} files)` : ""}
|
||||
${context.sessionHistory.totalSessions > 0 ? `- Complete Cursor session history (${context.sessionHistory.totalSessions} sessions, ${context.sessionHistory.messages.length} messages in chronological order)` : ""}
|
||||
|
||||
Use this context to provide specific, grounded responses. The session history shows your complete conversation history with the user - use it to understand what has been built and discussed.`;
|
||||
|
||||
// Load existing conversation history from Postgres
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const convRows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
const conversationHistory: any[] = convRows[0]?.messages ?? [];
|
||||
// Load existing conversation history from Postgres
|
||||
await query(ENSURE_CONV_TABLE);
|
||||
const convRows = await query<{ messages: any[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId],
|
||||
);
|
||||
const conversationHistory: any[] = convRows[0]?.messages ?? [];
|
||||
|
||||
// Build full message context (history + current message)
|
||||
const messages = [
|
||||
...conversationHistory.map((msg: any) => ({
|
||||
role: msg.role as 'user' | 'assistant',
|
||||
content: msg.content as string,
|
||||
})),
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: message,
|
||||
},
|
||||
];
|
||||
// Build full message context (history + current message)
|
||||
const messages = [
|
||||
...conversationHistory.map((msg: any) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content as string,
|
||||
})),
|
||||
{
|
||||
role: "user" as const,
|
||||
content: message,
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`);
|
||||
console.log(`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`);
|
||||
|
||||
// Log system prompt length
|
||||
console.log(`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`);
|
||||
|
||||
// Log each message length
|
||||
messages.forEach((msg, i) => {
|
||||
console.log(`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`);
|
||||
});
|
||||
|
||||
const totalInputChars = enhancedSystemPrompt.length + messages.reduce((sum, msg) => sum + msg.content.length, 0);
|
||||
console.log(`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`);
|
||||
|
||||
// Log system prompt preview (first 500 chars)
|
||||
console.log(`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`);
|
||||
|
||||
// Log last user message
|
||||
const lastUserMsg = messages[messages.length - 1];
|
||||
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
|
||||
|
||||
// Safety check: extraction_review_mode requires extraction results
|
||||
if (resolvedMode === 'extraction_review_mode' && !context.phaseHandoffs?.extraction) {
|
||||
console.warn(`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`);
|
||||
}
|
||||
console.log(
|
||||
`[AI Chat] Sending ${messages.length} messages to LLM (${conversationHistory.length} from history + 1 new)`,
|
||||
);
|
||||
console.log(
|
||||
`[AI Chat] Mode: ${resolvedMode}, Phase: ${projectData.currentPhase}, Has extraction: ${!!context.phaseHandoffs?.extraction}`,
|
||||
);
|
||||
|
||||
const llm: LlmClient = new GeminiLlmClient();
|
||||
// Log system prompt length
|
||||
console.log(
|
||||
`[AI Chat] System prompt length: ${enhancedSystemPrompt.length} chars (~${Math.ceil(enhancedSystemPrompt.length / 4)} tokens)`,
|
||||
);
|
||||
|
||||
// Configure thinking mode based on task complexity
|
||||
// Simple modes (collector, extraction_review) don't need deep thinking
|
||||
// Complex modes (mvp, vision) benefit from extended reasoning
|
||||
const needsThinking = resolvedMode === 'mvp_mode' || resolvedMode === 'vision_mode';
|
||||
// Log each message length
|
||||
messages.forEach((msg, i) => {
|
||||
console.log(
|
||||
`[AI Chat] Message ${i + 1} (${msg.role}): ${msg.content.length} chars (~${Math.ceil(msg.content.length / 4)} tokens)`,
|
||||
);
|
||||
});
|
||||
|
||||
const reply = await llm.structuredCall<{
|
||||
reply: string;
|
||||
visionAnswers?: {
|
||||
q1?: string;
|
||||
q2?: string;
|
||||
q3?: string;
|
||||
allAnswered?: boolean;
|
||||
};
|
||||
collectorHandoff?: {
|
||||
hasDocuments?: boolean;
|
||||
documentCount?: number;
|
||||
githubConnected?: boolean;
|
||||
githubRepo?: string;
|
||||
extensionLinked?: boolean;
|
||||
extensionDeclined?: boolean;
|
||||
noGithubYet?: boolean;
|
||||
readyForExtraction?: boolean;
|
||||
};
|
||||
extractionReviewHandoff?: {
|
||||
extractionApproved?: boolean;
|
||||
readyForVision?: boolean;
|
||||
};
|
||||
}>({
|
||||
model: 'gemini',
|
||||
systemPrompt: enhancedSystemPrompt,
|
||||
messages: messages, // Full conversation history!
|
||||
schema: ChatReplySchema,
|
||||
temperature: 0.4,
|
||||
thinking_config: needsThinking ? {
|
||||
thinking_level: 'high',
|
||||
include_thoughts: false,
|
||||
} : undefined,
|
||||
});
|
||||
const totalInputChars =
|
||||
enhancedSystemPrompt.length +
|
||||
messages.reduce((sum, msg) => sum + msg.content.length, 0);
|
||||
console.log(
|
||||
`[AI Chat] Total input: ${totalInputChars} chars (~${Math.ceil(totalInputChars / 4)} tokens)`,
|
||||
);
|
||||
|
||||
// Store all vision answers when provided
|
||||
if (reply.visionAnswers) {
|
||||
const updates: any = {};
|
||||
|
||||
if (reply.visionAnswers.q1) {
|
||||
updates['visionAnswers.q1'] = reply.visionAnswers.q1;
|
||||
console.log('[AI Chat] Storing vision answer Q1');
|
||||
// Log system prompt preview (first 500 chars)
|
||||
console.log(
|
||||
`[AI Chat] System prompt preview: ${enhancedSystemPrompt.substring(0, 500)}...`,
|
||||
);
|
||||
|
||||
// Log last user message
|
||||
const lastUserMsg = messages[messages.length - 1];
|
||||
console.log(`[AI Chat] User message: ${lastUserMsg.content}`);
|
||||
|
||||
// Safety check: extraction_review_mode requires extraction results
|
||||
if (
|
||||
resolvedMode === "extraction_review_mode" &&
|
||||
!context.phaseHandoffs?.extraction
|
||||
) {
|
||||
console.warn(
|
||||
`[AI Chat] WARNING: extraction_review_mode active but no extraction results found for project ${projectId}`,
|
||||
);
|
||||
}
|
||||
if (reply.visionAnswers.q2) {
|
||||
updates['visionAnswers.q2'] = reply.visionAnswers.q2;
|
||||
console.log('[AI Chat] Storing vision answer Q2');
|
||||
}
|
||||
if (reply.visionAnswers.q3) {
|
||||
updates['visionAnswers.q3'] = reply.visionAnswers.q3;
|
||||
console.log('[AI Chat] Storing vision answer Q3');
|
||||
}
|
||||
|
||||
// If all answers are complete, trigger MVP generation
|
||||
if (reply.visionAnswers.allAnswered) {
|
||||
updates['visionAnswers.allAnswered'] = true;
|
||||
updates['readyForMVP'] = true;
|
||||
console.log('[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation');
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates['visionAnswers.updatedAt'] = new Date().toISOString();
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
|
||||
const llm: LlmClient = new GeminiLlmClient();
|
||||
|
||||
// Configure thinking mode based on task complexity
|
||||
// Simple modes (collector, extraction_review) don't need deep thinking
|
||||
// Complex modes (mvp, vision) benefit from extended reasoning
|
||||
const needsThinking =
|
||||
resolvedMode === "mvp_mode" || resolvedMode === "vision_mode";
|
||||
|
||||
const reply = await llm.structuredCall<{
|
||||
reply: string;
|
||||
visionAnswers?: {
|
||||
q1?: string;
|
||||
q2?: string;
|
||||
q3?: string;
|
||||
allAnswered?: boolean;
|
||||
};
|
||||
collectorHandoff?: {
|
||||
hasDocuments?: boolean;
|
||||
documentCount?: number;
|
||||
githubConnected?: boolean;
|
||||
githubRepo?: string;
|
||||
extensionLinked?: boolean;
|
||||
extensionDeclined?: boolean;
|
||||
noGithubYet?: boolean;
|
||||
readyForExtraction?: boolean;
|
||||
};
|
||||
extractionReviewHandoff?: {
|
||||
extractionApproved?: boolean;
|
||||
readyForVision?: boolean;
|
||||
};
|
||||
}>({
|
||||
model: "gemini",
|
||||
systemPrompt: enhancedSystemPrompt,
|
||||
messages: messages, // Full conversation history!
|
||||
schema: ChatReplySchema,
|
||||
temperature: 0.4,
|
||||
thinking_config: needsThinking
|
||||
? {
|
||||
thinking_level: "high",
|
||||
include_thoughts: false,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Store all vision answers when provided
|
||||
if (reply.visionAnswers) {
|
||||
const updates: any = {};
|
||||
|
||||
if (reply.visionAnswers.q1) {
|
||||
updates["visionAnswers.q1"] = reply.visionAnswers.q1;
|
||||
console.log("[AI Chat] Storing vision answer Q1");
|
||||
}
|
||||
if (reply.visionAnswers.q2) {
|
||||
updates["visionAnswers.q2"] = reply.visionAnswers.q2;
|
||||
console.log("[AI Chat] Storing vision answer Q2");
|
||||
}
|
||||
if (reply.visionAnswers.q3) {
|
||||
updates["visionAnswers.q3"] = reply.visionAnswers.q3;
|
||||
console.log("[AI Chat] Storing vision answer Q3");
|
||||
}
|
||||
|
||||
// If all answers are complete, trigger MVP generation
|
||||
if (reply.visionAnswers.allAnswered) {
|
||||
updates["visionAnswers.allAnswered"] = true;
|
||||
updates["readyForMVP"] = true;
|
||||
console.log(
|
||||
"[AI Chat] ✅ All 3 vision answers complete - ready for MVP generation",
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates["visionAnswers.updatedAt"] = new Date().toISOString();
|
||||
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || $1::jsonb
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify({ visionAnswers: updates }), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to store vision answers', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: append this turn to the persisted conversation history
|
||||
appendConversation(projectId, [
|
||||
{ role: 'user', content: message },
|
||||
{ role: 'assistant', content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error('[ai/chat] Failed to append conversation history', error);
|
||||
});
|
||||
|
||||
// If in collector mode, always update handoff state based on actual project context
|
||||
// This ensures the checklist updates even if AI doesn't return collectorHandoff
|
||||
if (resolvedMode === 'collector_mode') {
|
||||
// Derive handoff state from actual project context
|
||||
const hasDocuments = (context.knowledgeSummary.bySourceType['imported_document'] ?? 0) > 0;
|
||||
const documentCount = context.knowledgeSummary.bySourceType['imported_document'] ?? 0;
|
||||
const githubConnected = !!context.project.githubRepo;
|
||||
const extensionLinked = context.project.extensionLinked ?? false;
|
||||
|
||||
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
|
||||
let readyForExtraction = reply.collectorHandoff?.readyForExtraction ?? false;
|
||||
|
||||
// Fallback: If AI says certain phrases, assume user confirmed readiness
|
||||
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
|
||||
if (!readyForExtraction && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for explicit analysis/digging phrases (not just "perfect!")
|
||||
const analysisKeywords = ['analyze', 'analyzing', 'digging', 'extraction', 'processing'];
|
||||
const hasAnalysisKeyword = analysisKeywords.some(keyword => replyLower.includes(keyword));
|
||||
|
||||
// Only trigger if AI mentions BOTH readiness AND analysis action
|
||||
if (hasAnalysisKeyword) {
|
||||
const confirmPhrases = [
|
||||
'let me analyze what you',
|
||||
'i\'ll start digging into',
|
||||
'i\'m starting the analysis',
|
||||
'running the extraction',
|
||||
'processing what you\'ve shared',
|
||||
];
|
||||
readyForExtraction = confirmPhrases.some(phrase => replyLower.includes(phrase));
|
||||
|
||||
if (readyForExtraction) {
|
||||
console.log(`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
|
||||
}
|
||||
[JSON.stringify({ visionAnswers: updates }), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to store vision answers", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handoff: CollectorPhaseHandoff = {
|
||||
phase: 'collector',
|
||||
readyForNextPhase: readyForExtraction,
|
||||
confidence: readyForExtraction ? 0.9 : 0.5,
|
||||
confirmed: {
|
||||
hasDocuments,
|
||||
documentCount,
|
||||
githubConnected,
|
||||
githubRepo: context.project.githubRepo ?? undefined,
|
||||
extensionLinked,
|
||||
},
|
||||
uncertain: {
|
||||
extensionDeclined: reply.collectorHandoff?.extensionDeclined ?? false,
|
||||
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
|
||||
},
|
||||
missing: [],
|
||||
questionsForUser: [],
|
||||
sourceEvidence: [],
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
// Best-effort: append this turn to the persisted conversation history
|
||||
appendConversation(projectId, [
|
||||
{ role: "user", content: message },
|
||||
{ role: "assistant", content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error("[ai/chat] Failed to append conversation history", error);
|
||||
});
|
||||
|
||||
// Persist to project phaseData in Postgres
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
// If in collector mode, always update handoff state based on actual project context
|
||||
// This ensures the checklist updates even if AI doesn't return collectorHandoff
|
||||
if (resolvedMode === "collector_mode") {
|
||||
// Derive handoff state from actual project context
|
||||
const hasDocuments =
|
||||
(context.knowledgeSummary.bySourceType["imported_document"] ?? 0) > 0;
|
||||
const documentCount =
|
||||
context.knowledgeSummary.bySourceType["imported_document"] ?? 0;
|
||||
const githubConnected = !!context.project.githubRepo;
|
||||
const extensionLinked = context.project.extensionLinked ?? false;
|
||||
|
||||
// Check if AI indicated readiness (from reply if provided, otherwise check reply text)
|
||||
let readyForExtraction =
|
||||
reply.collectorHandoff?.readyForExtraction ?? false;
|
||||
|
||||
// Fallback: If AI says certain phrases, assume user confirmed readiness
|
||||
// IMPORTANT: These phrases must be SPECIFIC to avoid false positives
|
||||
if (!readyForExtraction && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for explicit analysis/digging phrases (not just "perfect!")
|
||||
const analysisKeywords = [
|
||||
"analyze",
|
||||
"analyzing",
|
||||
"digging",
|
||||
"extraction",
|
||||
"processing",
|
||||
];
|
||||
const hasAnalysisKeyword = analysisKeywords.some((keyword) =>
|
||||
replyLower.includes(keyword),
|
||||
);
|
||||
|
||||
// Only trigger if AI mentions BOTH readiness AND analysis action
|
||||
if (hasAnalysisKeyword) {
|
||||
const confirmPhrases = [
|
||||
"let me analyze what you",
|
||||
"i'll start digging into",
|
||||
"i'm starting the analysis",
|
||||
"running the extraction",
|
||||
"processing what you've shared",
|
||||
];
|
||||
readyForExtraction = confirmPhrases.some((phrase) =>
|
||||
replyLower.includes(phrase),
|
||||
);
|
||||
|
||||
if (readyForExtraction) {
|
||||
console.log(
|
||||
`[AI Chat] Detected readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handoff: CollectorPhaseHandoff = {
|
||||
phase: "collector",
|
||||
readyForNextPhase: readyForExtraction,
|
||||
confidence: readyForExtraction ? 0.9 : 0.5,
|
||||
confirmed: {
|
||||
hasDocuments,
|
||||
documentCount,
|
||||
githubConnected,
|
||||
githubRepo: context.project.githubRepo ?? undefined,
|
||||
extensionLinked,
|
||||
},
|
||||
uncertain: {
|
||||
extensionDeclined:
|
||||
reply.collectorHandoff?.extensionDeclined ?? false,
|
||||
noGithubYet: reply.collectorHandoff?.noGithubYet ?? false,
|
||||
},
|
||||
missing: [],
|
||||
questionsForUser: [],
|
||||
sourceEvidence: [],
|
||||
version: "1.0",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Persist to project phaseData in Postgres
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(
|
||||
data,
|
||||
'{phaseData,phaseHandoffs,collector}',
|
||||
@@ -335,77 +393,93 @@ Use this context to provide specific, grounded responses. The session history sh
|
||||
true
|
||||
)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(handoff), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to persist collector handoff', error);
|
||||
});
|
||||
[JSON.stringify(handoff), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to persist collector handoff", error);
|
||||
});
|
||||
|
||||
console.log(`[AI Chat] Collector handoff persisted:`, {
|
||||
hasDocuments: handoff.confirmed.hasDocuments,
|
||||
githubConnected: handoff.confirmed.githubConnected,
|
||||
extensionLinked: handoff.confirmed.extensionLinked,
|
||||
readyForExtraction: handoff.readyForNextPhase,
|
||||
});
|
||||
console.log(`[AI Chat] Collector handoff persisted:`, {
|
||||
hasDocuments: handoff.confirmed.hasDocuments,
|
||||
githubConnected: handoff.confirmed.githubConnected,
|
||||
extensionLinked: handoff.confirmed.extensionLinked,
|
||||
readyForExtraction: handoff.readyForNextPhase,
|
||||
});
|
||||
|
||||
// Auto-transition to extraction phase if ready
|
||||
if (handoff.readyForNextPhase) {
|
||||
console.log(`[AI Chat] Collector complete - triggering backend extraction`);
|
||||
|
||||
// Mark collector as complete
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
// Auto-transition to extraction phase if ready
|
||||
if (handoff.readyForNextPhase) {
|
||||
console.log(
|
||||
`[AI Chat] Collector complete - triggering backend extraction`,
|
||||
);
|
||||
|
||||
// Mark collector as complete
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{phaseData,collectorCompletedAt}', $1::jsonb, true)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(new Date().toISOString()), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to mark collector complete', error);
|
||||
});
|
||||
|
||||
// Trigger backend extraction (async - don't await)
|
||||
import('@/lib/server/backend-extractor').then(({ runBackendExtractionForProject }) => {
|
||||
runBackendExtractionForProject(projectId).catch((error) => {
|
||||
console.error(`[AI Chat] Backend extraction failed for project ${projectId}:`, error);
|
||||
[JSON.stringify(new Date().toISOString()), projectId],
|
||||
).catch((error) => {
|
||||
console.error("[ai/chat] Failed to mark collector complete", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle extraction review → vision phase transition
|
||||
if (resolvedMode === 'extraction_review_mode') {
|
||||
// Check if AI indicated extraction is approved and ready for vision
|
||||
let readyForVision = reply.extractionReviewHandoff?.readyForVision ?? false;
|
||||
|
||||
// Fallback: Check reply text for approval phrases
|
||||
if (!readyForVision && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for vision transition phrases
|
||||
const visionKeywords = ['vision', 'mvp', 'roadmap', 'plan'];
|
||||
const hasVisionKeyword = visionKeywords.some(keyword => replyLower.includes(keyword));
|
||||
|
||||
if (hasVisionKeyword) {
|
||||
const confirmPhrases = [
|
||||
'ready to move to',
|
||||
'ready for vision',
|
||||
'let\'s move to vision',
|
||||
'moving to vision',
|
||||
'great! let\'s define',
|
||||
'perfect! now let\'s',
|
||||
];
|
||||
readyForVision = confirmPhrases.some(phrase => replyLower.includes(phrase));
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`);
|
||||
}
|
||||
// Trigger backend extraction (async - don't await)
|
||||
import("@/lib/server/backend-extractor").then(
|
||||
({ runBackendExtractionForProject }) => {
|
||||
runBackendExtractionForProject(projectId).catch((error) => {
|
||||
console.error(
|
||||
`[AI Chat] Backend extraction failed for project ${projectId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(`[AI Chat] Extraction review complete - transitioning to vision phase`);
|
||||
|
||||
// Mark extraction review as complete and transition to vision
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
|
||||
// Handle extraction review → vision phase transition
|
||||
if (resolvedMode === "extraction_review_mode") {
|
||||
// Check if AI indicated extraction is approved and ready for vision
|
||||
let readyForVision =
|
||||
reply.extractionReviewHandoff?.readyForVision ?? false;
|
||||
|
||||
// Fallback: Check reply text for approval phrases
|
||||
if (!readyForVision && reply.reply) {
|
||||
const replyLower = reply.reply.toLowerCase();
|
||||
|
||||
// Check for vision transition phrases
|
||||
const visionKeywords = ["vision", "mvp", "roadmap", "plan"];
|
||||
const hasVisionKeyword = visionKeywords.some((keyword) =>
|
||||
replyLower.includes(keyword),
|
||||
);
|
||||
|
||||
if (hasVisionKeyword) {
|
||||
const confirmPhrases = [
|
||||
"ready to move to",
|
||||
"ready for vision",
|
||||
"let's move to vision",
|
||||
"moving to vision",
|
||||
"great! let's define",
|
||||
"perfect! now let's",
|
||||
];
|
||||
readyForVision = confirmPhrases.some((phrase) =>
|
||||
replyLower.includes(phrase),
|
||||
);
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(
|
||||
`[AI Chat] Detected vision readiness from AI reply text: "${reply.reply.substring(0, 100)}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readyForVision) {
|
||||
console.log(
|
||||
`[AI Chat] Extraction review complete - transitioning to vision phase`,
|
||||
);
|
||||
|
||||
// Mark extraction review as complete and transition to vision
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data
|
||||
|| '{"currentPhase":"vision","phaseStatus":"in_progress"}'::jsonb
|
||||
|| jsonb_build_object('phaseData',
|
||||
@@ -414,86 +488,99 @@ Use this context to provide specific, grounded responses. The session history sh
|
||||
)
|
||||
)
|
||||
WHERE id = $2`,
|
||||
[new Date().toISOString(), projectId]
|
||||
).catch((error) => {
|
||||
console.error('[ai/chat] Failed to transition to vision phase', error);
|
||||
});
|
||||
[new Date().toISOString(), projectId],
|
||||
).catch((error) => {
|
||||
console.error(
|
||||
"[ai/chat] Failed to transition to vision phase",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save conversation history to Postgres
|
||||
await appendConversation(projectId, [
|
||||
{ role: 'user', content: message },
|
||||
{ role: 'assistant', content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error('[ai/chat] Failed to save conversation history', error);
|
||||
});
|
||||
// Save conversation history to Postgres
|
||||
await appendConversation(projectId, [
|
||||
{ role: "user", content: message },
|
||||
{ role: "assistant", content: reply.reply },
|
||||
]).catch((error) => {
|
||||
console.error("[ai/chat] Failed to save conversation history", error);
|
||||
});
|
||||
|
||||
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
|
||||
console.log(`[AI Chat] Conversation history saved (+2 messages)`);
|
||||
|
||||
// Determine which artifacts were used
|
||||
const artifactsUsed = determineArtifactsUsed(context);
|
||||
// Determine which artifacts were used
|
||||
const artifactsUsed = determineArtifactsUsed(context);
|
||||
|
||||
// Log successful interaction
|
||||
logProjectEvent({
|
||||
projectId,
|
||||
userId: projectData.userId ?? null,
|
||||
eventType: 'chat_interaction',
|
||||
mode: resolvedMode,
|
||||
phase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
vectorChunkCount: context.retrievedChunks.length,
|
||||
promptVersion: '2.0', // Updated with vector search
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
knowledgeCount: context.knowledgeSummary.totalCount,
|
||||
extractionCount: context.extractionSummary.totalCount,
|
||||
hasGithubRepo: !!context.repositoryAnalysis,
|
||||
},
|
||||
}).catch((err) => console.error('[ai/chat] Failed to log event:', err));
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply.reply,
|
||||
mode: resolvedMode,
|
||||
projectPhase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ai/chat] Error handling chat request', error);
|
||||
|
||||
// Log error (best-effort) - extract projectId from request body if available
|
||||
const errorProjectId = typeof (error as { projectId?: string })?.projectId === 'string'
|
||||
? (error as { projectId: string }).projectId
|
||||
: null;
|
||||
|
||||
if (errorProjectId) {
|
||||
// Log successful interaction
|
||||
logProjectEvent({
|
||||
projectId: errorProjectId,
|
||||
userId: null,
|
||||
eventType: 'error',
|
||||
mode: null,
|
||||
phase: null,
|
||||
artifactsUsed: [],
|
||||
usedVectorSearch: false,
|
||||
promptVersion: '2.0',
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || 'gemini-3-pro-preview',
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}).catch((err) => console.error('[ai/chat] Failed to log error:', err));
|
||||
projectId,
|
||||
userId: projectData.userId ?? null,
|
||||
eventType: "chat_interaction",
|
||||
mode: resolvedMode,
|
||||
phase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
vectorChunkCount: context.retrievedChunks.length,
|
||||
promptVersion: "2.0", // Updated with vector search
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
|
||||
success: true,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
knowledgeCount: context.knowledgeSummary.totalCount,
|
||||
extractionCount: context.extractionSummary.totalCount,
|
||||
hasGithubRepo: !!context.repositoryAnalysis,
|
||||
},
|
||||
}).catch((err) => console.error("[ai/chat] Failed to log event:", err));
|
||||
|
||||
return NextResponse.json({
|
||||
reply: reply.reply,
|
||||
mode: resolvedMode,
|
||||
projectPhase: projectData.currentPhase ?? null,
|
||||
artifactsUsed,
|
||||
usedVectorSearch: context.retrievedChunks.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ai/chat] Error handling chat request", error);
|
||||
|
||||
// Log error (best-effort) - extract projectId from request body if available
|
||||
const errorProjectId =
|
||||
typeof (error as { projectId?: string })?.projectId === "string"
|
||||
? (error as { projectId: string }).projectId
|
||||
: null;
|
||||
|
||||
if (errorProjectId) {
|
||||
logProjectEvent({
|
||||
projectId: errorProjectId,
|
||||
userId: null,
|
||||
eventType: "error",
|
||||
mode: null,
|
||||
phase: null,
|
||||
artifactsUsed: [],
|
||||
usedVectorSearch: false,
|
||||
promptVersion: "2.0",
|
||||
modelUsed: process.env.VERTEX_AI_MODEL || "gemini-3-pro-preview",
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
}).catch((err) =>
|
||||
log.error("ai/chat log failed", {
|
||||
route: "api.ai.chat",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.error("ai/chat error", {
|
||||
route: "api.ai.chat",
|
||||
err: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to process chat message",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to process chat message',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
{ source: "body", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
/**
|
||||
* POST /api/ai/conversation/reset
|
||||
* Body OR query: { projectId }
|
||||
*
|
||||
* Deletes the conversation history for a project the caller owns.
|
||||
*
|
||||
* Closes S-03. Also migrated off the legacy Firebase admin path onto Postgres
|
||||
* to match `/api/ai/conversation`.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const body = await request
|
||||
.json()
|
||||
.catch(() => ({ projectId: url.searchParams.get('projectId') }));
|
||||
|
||||
const projectId = (body?.projectId ?? url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
export const POST = withTenantProject(
|
||||
async (_request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
|
||||
project.id,
|
||||
]);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error("ai/conversation/reset failed", {
|
||||
route: "api.ai.conversation.reset",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId is required' },
|
||||
{ status: 400 },
|
||||
{
|
||||
error: "Failed to reset conversation",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
const docRef = adminDb.collection('chat_conversations').doc(projectId);
|
||||
await docRef.delete();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[ai/conversation/reset] Failed to reset conversation', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to reset conversation',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
},
|
||||
{ source: "body", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
/**
|
||||
* GET /api/ai/conversation?projectId=… — fetch saved conversation
|
||||
* DELETE /api/ai/conversation?projectId=… — wipe saved conversation
|
||||
*
|
||||
* Closes S-02: was completely unauthenticated and accepted any projectId.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const ENSURE_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS chat_conversations (
|
||||
@@ -9,7 +17,7 @@ const ENSURE_TABLE = `
|
||||
)
|
||||
`;
|
||||
|
||||
type StoredMessageRole = 'user' | 'assistant';
|
||||
type StoredMessageRole = "user" | "assistant";
|
||||
|
||||
type ConversationMessage = {
|
||||
role: StoredMessageRole;
|
||||
@@ -21,49 +29,48 @@ type ConversationResponse = {
|
||||
messages: ConversationMessage[];
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
|
||||
export const GET = withTenantProject(
|
||||
async (request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(ENSURE_TABLE);
|
||||
const rows = await query<{ messages: ConversationMessage[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[project.id],
|
||||
);
|
||||
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
|
||||
const response: ConversationResponse = { messages };
|
||||
return NextResponse.json(response);
|
||||
} catch (err) {
|
||||
log.error("ai/conversation GET failed", {
|
||||
route: "api.ai.conversation",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
await query(ENSURE_TABLE);
|
||||
|
||||
const rows = await query<{ messages: ConversationMessage[] }>(
|
||||
`SELECT messages FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
const messages: ConversationMessage[] = rows[0]?.messages ?? [];
|
||||
const response: ConversationResponse = { messages };
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('[GET /api/ai/conversation] Error:', error);
|
||||
return NextResponse.json({ messages: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'projectId is required' }, { status: 400 });
|
||||
export const DELETE = withTenantProject(
|
||||
async (_request, _ctx, { project }) => {
|
||||
try {
|
||||
await query(ENSURE_TABLE);
|
||||
await query(`DELETE FROM chat_conversations WHERE project_id = $1`, [
|
||||
project.id,
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error("ai/conversation DELETE failed", {
|
||||
route: "api.ai.conversation",
|
||||
projectId: project.id,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reset conversation" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await query(ENSURE_TABLE);
|
||||
await query(
|
||||
`DELETE FROM chat_conversations WHERE project_id = $1`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[DELETE /api/ai/conversation] Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to reset conversation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
@@ -33,11 +33,10 @@ import { buildDesignKitPromptSection } from "@/lib/design-kits/for-ai";
|
||||
import { buildCodebaseSummary } from "@/lib/ai/project-context/codebase-summary";
|
||||
import type { ChatMessage, ToolCall } from "@/lib/ai/gemini-chat";
|
||||
|
||||
// Path B chains routinely fire 7-10 tool calls in one user turn. 18
|
||||
// gives enough headroom for complex workflows (scaffold → install →
|
||||
// configure → start) while still capping runaway loops. When the cap
|
||||
// IS hit, we emit a recovery summary instead of silent tool pills.
|
||||
const MAX_TOOL_ROUNDS = 15;
|
||||
// C-01: Lowered from 15 → 8. Real workflows (scaffold → install →
|
||||
// configure → start) rarely need more than 8 rounds when done correctly.
|
||||
// If the cap IS hit the model gets a recovery summary, not silence.
|
||||
const MAX_TOOL_ROUNDS = 8;
|
||||
|
||||
let chatTablesReady = false;
|
||||
async function ensureChatTables() {
|
||||
@@ -151,6 +150,20 @@ After every assistant turn, the harness automatically runs \`git add -A && git c
|
||||
|
||||
You're talking to the owner of the "${workspace}" workspace. They have admin access to their Gitea org, a fleet of Coolify projects, and a persistent dev container per project. You can read and write any of it.
|
||||
|
||||
## Mode: respond first, act second
|
||||
Before calling any tool, decide: is the user asking a question, or telling you to do something?
|
||||
|
||||
**CONVERSATIONAL inputs — respond with text only, no tools:**
|
||||
- One-word or greeting messages: "test", "hi", "ok", "thanks"
|
||||
- Questions ending in "?": "are you able to…?", "what does X mean?", "how would you…?"
|
||||
- Status checks: "is it deployed?", "what's running?" (one read-only tool MAX, then respond)
|
||||
|
||||
**ACTION inputs — tools allowed:**
|
||||
- Imperatives: "deploy it", "build me X", "fix the navbar", "ship"
|
||||
- Specific tasks with clear deliverables: "add Stripe to the pricing page"
|
||||
|
||||
If you are unsure which mode the user is in, **default to CONVERSATIONAL** and ask one clarifying sentence before acting. "Want me to actually deploy this to prod now, or were you just checking?" is always cheaper than a silent 16-tool spiral.
|
||||
|
||||
## Identity
|
||||
You are a high-agency product engineer. You own the outcome. Continue until the user's goal is actually resolved unless you're blocked on missing info, proceeding would be unsafe, or the user changes direction. You are not answering questions; you are building with the user. Translate engineering complexity into product momentum.
|
||||
|
||||
@@ -530,6 +543,9 @@ export async function POST(request: Request) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let streamClosed = false;
|
||||
// C-06: Per-turn correlation ID so prod logs are greppable.
|
||||
const turnId = crypto.randomUUID();
|
||||
|
||||
function emit(chunk: object) {
|
||||
if (streamClosed) return;
|
||||
try {
|
||||
@@ -544,11 +560,21 @@ export async function POST(request: Request) {
|
||||
function safeClose() {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// C-04: SSE heartbeat every 25s keeps Cloudflare / proxies from
|
||||
// dropping the connection during long Gemini thinking phases.
|
||||
const heartbeat = setInterval(() => {
|
||||
emit({ type: "ping", turnId });
|
||||
}, 25_000);
|
||||
|
||||
// Emit turnId immediately so the client can log/correlate.
|
||||
emit({ type: "turn_start", turnId });
|
||||
|
||||
let messages = [...history];
|
||||
let round = 0;
|
||||
let assistantText = "";
|
||||
@@ -616,6 +642,29 @@ export async function POST(request: Request) {
|
||||
return `${tc.name}:${argSig}`;
|
||||
}
|
||||
|
||||
// ── Server-side conversational guard (C-03 enforcement) ───────────
|
||||
// If the user's message looks conversational we withhold tools for
|
||||
// round 1. The model MUST respond in text first. If its reply then
|
||||
// expresses clear intent to act, tools become available from round 2.
|
||||
// This is more reliable than a prompt rule against a "do-er" model.
|
||||
function isConversational(msg: string): boolean {
|
||||
const m = msg.trim();
|
||||
if (m.length < 3) return true; // single word / emoji
|
||||
if (m.endsWith("?")) return true; // explicit question
|
||||
// Short phrases that are status checks or greetings
|
||||
const conversationalPatterns = [
|
||||
/^(hi|hey|hello|sup|test|ok|okay|thanks|ty|thx|lgtm|nice|cool|great|wow)\b/i,
|
||||
/^(what|how|why|when|where|who|which|is |are |can |could |would |do |does |did |has |have |had |was |were )\S+.{0,60}$/i,
|
||||
/^(are you able to|can you|could you|would you|is it possible)/i,
|
||||
/^(what'?s |whats )(running|live|deployed|happening|wrong|broken|up)/i,
|
||||
/^(is it|is that|is this|is there|is the)/i,
|
||||
];
|
||||
return conversationalPatterns.some((re) => re.test(m));
|
||||
}
|
||||
const firstMessageIsConversational =
|
||||
mcp_token !== undefined && // tools available
|
||||
isConversational(message.trim());
|
||||
|
||||
try {
|
||||
// Tool-calling loop: use non-streaming so thought_signature is
|
||||
// always present in the complete response (required by thinking models).
|
||||
@@ -623,7 +672,12 @@ export async function POST(request: Request) {
|
||||
if (aborted) break;
|
||||
round++;
|
||||
|
||||
const toolDefs = mcp_token ? VIBN_TOOL_DEFINITIONS : [];
|
||||
// On round 1, withhold tools if the message looks conversational.
|
||||
// The model must answer in text first; tools unlock from round 2.
|
||||
const toolDefs =
|
||||
mcp_token && !(round === 1 && firstMessageIsConversational)
|
||||
? VIBN_TOOL_DEFINITIONS
|
||||
: [];
|
||||
|
||||
// Every 2 silent rounds or 5 tool calls, nudge the model to surface a one-liner
|
||||
// status before continuing. This is the user's only signal of
|
||||
@@ -637,6 +691,16 @@ export async function POST(request: Request) {
|
||||
"on and why. The user is staring at silent tool pills."
|
||||
: "";
|
||||
|
||||
// When withholding tools on round 1 (conversational guard), add a
|
||||
// mandatory instruction so the model doesn't return empty text.
|
||||
if (round === 1 && firstMessageIsConversational) {
|
||||
extraSystem +=
|
||||
"\n\n[MANDATORY] The user's message is a question or conversational input, " +
|
||||
"not a command. You have NO tools available on this turn. " +
|
||||
"Respond with PLAIN TEXT ONLY in 1-3 sentences answering their question. " +
|
||||
"If they want you to take action, confirm intent and wait for a clear directive.";
|
||||
}
|
||||
|
||||
if (MAX_TOOL_ROUNDS - round <= 3) {
|
||||
extraSystem += `\n\n[WARNING] You only have ${MAX_TOOL_ROUNDS - round} tool calls left before you are forcefully terminated. Stop exploring, make your final edits, and write your final response to the user NOW.`;
|
||||
}
|
||||
@@ -713,14 +777,17 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Warning at 3 repeats
|
||||
if (maxRepeats === 3) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
|
||||
// C-02: Tightened. Hard-break at 3 identical fingerprints (was 5).
|
||||
if (maxRepeats === 2) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} twice in a row. Try a different approach or surface what's blocking you to the user.`;
|
||||
}
|
||||
if (maxRepeats >= 3) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
}
|
||||
|
||||
// Stage 2: Hard Break at 5 repeats
|
||||
if (maxRepeats >= 5) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
// C-02: Also hard-break after 6 consecutive tool calls with no text.
|
||||
if (!loopBreakReason && toolCallsSinceText >= 6) {
|
||||
loopBreakReason = `${toolCallsSinceText} consecutive tool calls with no assistant text`;
|
||||
}
|
||||
|
||||
// Execute tool calls and add results. OpenAI-compatible APIs
|
||||
@@ -730,15 +797,32 @@ export async function POST(request: Request) {
|
||||
const recoveryLines: string[] = [];
|
||||
for (const tc of resp.toolCalls) {
|
||||
if (aborted) break;
|
||||
const result = mcp_token
|
||||
? await executeMcpTool(
|
||||
// C-05: Per-tool timeout. A hung MCP call would freeze the whole turn.
|
||||
const TOOL_TIMEOUT_MS = 45_000;
|
||||
const toolTimeout = new Promise<string>((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: `Tool ${tc.name} timed out after ${TOOL_TIMEOUT_MS / 1000}s`,
|
||||
}),
|
||||
),
|
||||
TOOL_TIMEOUT_MS,
|
||||
),
|
||||
);
|
||||
const toolExec = mcp_token
|
||||
? executeMcpTool(
|
||||
tc.name,
|
||||
tc.args,
|
||||
mcp_token,
|
||||
baseUrl,
|
||||
activeProject?.id,
|
||||
)
|
||||
: JSON.stringify({ error: "No MCP token — read-only mode." });
|
||||
: Promise.resolve(
|
||||
JSON.stringify({ error: "No MCP token — read-only mode." }),
|
||||
);
|
||||
const result = await Promise.race([toolExec, toolTimeout]);
|
||||
|
||||
emit({
|
||||
type: "tool_result",
|
||||
@@ -756,6 +840,25 @@ export async function POST(request: Request) {
|
||||
|
||||
const recovery = detectKnownError(result);
|
||||
if (recovery) recoveryLines.push(formatRecoveryMessage(recovery));
|
||||
|
||||
// B-05: SSE plan event — stream task state changes to the client
|
||||
// so the Plan tab updates in real-time during a chat turn.
|
||||
if (tc.name === "plan_task_add" || tc.name === "plan_task_edit") {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
const task = parsed?.result?.task ?? parsed?.task;
|
||||
if (task?.id) {
|
||||
emit({
|
||||
type: "plan",
|
||||
taskId: task.id,
|
||||
text: task.text ?? task.title ?? "",
|
||||
status: task.status ?? "open",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// non-JSON result — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const line of recoveryLines) {
|
||||
messages.push({ role: "user", content: line });
|
||||
@@ -787,12 +890,15 @@ export async function POST(request: Request) {
|
||||
// 20 toolCalls, user had to re-prompt to get any answer.
|
||||
const lastTurnHadTools =
|
||||
messages.length > 0 && messages[messages.length - 1].role === "tool";
|
||||
// C-07: Also recover when the model has been running tools without
|
||||
// any text for >=4 rounds — the user is staring at silence.
|
||||
const needsRecovery =
|
||||
!aborted &&
|
||||
lastTurnHadTools &&
|
||||
(round >= MAX_TOOL_ROUNDS ||
|
||||
!!loopBreakReason ||
|
||||
assistantText.trim().length === 0 ||
|
||||
roundsSinceText >= 4 ||
|
||||
lastToolResultsHadFailure(messages));
|
||||
|
||||
if (needsRecovery) {
|
||||
@@ -1072,9 +1178,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Browser disconnected (tab closed, navigated away). Nothing to
|
||||
// do — the abort handler above already flipped the flag and the
|
||||
// loop will bail at the next checkpoint.
|
||||
// Browser disconnected (tab closed, navigated away). Clear the
|
||||
// heartbeat so we stop writing to a closed stream.
|
||||
// The abort handler above already flipped the flag so the loop bails.
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
25
vibn-frontend/app/api/chat/route.ts.sed
Normal file
25
vibn-frontend/app/api/chat/route.ts.sed
Normal file
@@ -0,0 +1,25 @@
|
||||
// Two-stage Loop detection (Fix 4 update)
|
||||
// Sliding window of 10
|
||||
const window = toolFingerprints.slice(-10);
|
||||
const counts = new Map<string, number>();
|
||||
for (const fp of window) counts.set(fp, (counts.get(fp) ?? 0) + 1);
|
||||
|
||||
// Find highest repeating tool call
|
||||
let maxRepeats = 0;
|
||||
let repeatedCmd = "";
|
||||
for (const [fp, n] of counts.entries()) {
|
||||
if (n > maxRepeats) {
|
||||
maxRepeats = n;
|
||||
repeatedCmd = fp.split("|")[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Warning at 3 repeats
|
||||
if (maxRepeats === 3) {
|
||||
extraSystem += `\n\n[WARNING] You have called ${repeatedCmd} 3 times recently. Please wrap up this approach or try a completely different tool.`;
|
||||
}
|
||||
|
||||
// Stage 2: Hard Break at 5 repeats
|
||||
if (maxRepeats >= 5) {
|
||||
loopBreakReason = `Repeated ${repeatedCmd} ${maxRepeats}× in last 10 calls`;
|
||||
}
|
||||
@@ -1,52 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
/**
|
||||
* POST /api/context/summarize
|
||||
* Body: { content: string, title?: string }
|
||||
*
|
||||
* Generates a short summary via Gemini. Closes S-04: now requires a
|
||||
* signed-in user (rate-limit per user, not per-IP) so we don't burn Gemini
|
||||
* quota on anonymous traffic.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAuth, withRateLimit } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||
const API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||
const MODEL = process.env.GEMINI_MODEL || "gemini-3.1-pro-preview";
|
||||
const API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { content, title } = await request.json();
|
||||
export const POST = withRateLimit(
|
||||
withAuth(async (request, _ctx, { user }) => {
|
||||
try {
|
||||
const { content, title } = (await request.json()) as {
|
||||
content?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "Content is required" }, { status: 400 });
|
||||
}
|
||||
if (!content || typeof content !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "content is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const maxContentLength = 30000;
|
||||
const truncatedContent = content.length > maxContentLength
|
||||
? content.substring(0, maxContentLength) + "..."
|
||||
: content;
|
||||
const maxContentLength = 30000;
|
||||
const truncatedContent =
|
||||
content.length > maxContentLength
|
||||
? content.substring(0, maxContentLength) + "..."
|
||||
: content;
|
||||
|
||||
const prompt = `Read this document titled "${title}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
|
||||
const prompt = `Read this document titled "${title ?? "(untitled)"}" and provide a concise 1-2 sentence summary that captures the main topic and key points. Be specific and actionable.
|
||||
|
||||
Document content:
|
||||
${truncatedContent}
|
||||
|
||||
Summary:`;
|
||||
|
||||
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.3 },
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${GEMINI_URL}?key=${API_KEY}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.3 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini API error (${response.status}): ${await response.text()}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
log.warn("context/summarize gemini error", {
|
||||
route: "api.context.summarize",
|
||||
user: user.email,
|
||||
status: response.status,
|
||||
body: text.slice(0, 500),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Gemini API error (${response.status})`,
|
||||
details: text.slice(0, 500),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const summary =
|
||||
result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ||
|
||||
"Summary unavailable";
|
||||
return NextResponse.json({ summary });
|
||||
} catch (err) {
|
||||
log.error("context/summarize failed", {
|
||||
route: "api.context.summarize",
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate summary",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const summary = result.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || 'Summary unavailable';
|
||||
|
||||
return NextResponse.json({ summary });
|
||||
} catch (error) {
|
||||
console.error("Error generating summary:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate summary", details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}),
|
||||
{
|
||||
// 20 summaries / min / user — much higher than chat because they're cheap.
|
||||
limit: 20,
|
||||
windowMs: 60_000,
|
||||
keyFn: (_req, extra) => {
|
||||
const userEmail = (extra as { user?: { email?: string } })?.user?.email;
|
||||
return `context-summarize:${userEmail ?? "anon"}`;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminAuth, adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Verify authentication using API key
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized - Missing API key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = authHeader.substring(7);
|
||||
|
||||
// Look up user by API key
|
||||
const apiKeysSnapshot = await adminDb
|
||||
.collection('apiKeys')
|
||||
.where('key', '==', apiKey)
|
||||
.where('isActive', '==', true)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (apiKeysSnapshot.empty) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid API key' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiKeyDoc = apiKeysSnapshot.docs[0];
|
||||
const apiKeyData = apiKeyDoc.data();
|
||||
const userId = apiKeyData.userId;
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key not associated with user' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const {
|
||||
projectId,
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
conversations
|
||||
} = body;
|
||||
|
||||
if (!projectId || !conversations) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: projectId, conversations' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user has access to the project
|
||||
const projectRef = adminDb.collection('projects').doc(projectId);
|
||||
const projectDoc = await projectRef.get();
|
||||
|
||||
if (!projectDoc.exists) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Project not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectData = projectDoc.data();
|
||||
if (projectData?.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this project' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process and store conversations
|
||||
const { composers, workspaceFiles, totalGenerations } = conversations;
|
||||
let conversationCount = 0;
|
||||
let totalMessagesWritten = 0;
|
||||
|
||||
// Determine filtering keywords based on project context
|
||||
// TODO: Make this configurable per project
|
||||
const projectKeywords = ['vibn', 'project', 'extension', 'collector', 'cursor-monitor'];
|
||||
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
|
||||
|
||||
// Store each composer (chat session) as a separate document
|
||||
for (const composer of composers || []) {
|
||||
if (composer.type !== 'head') continue; // Only process head composers
|
||||
|
||||
const conversationId = `cursor-${composer.composerId}`;
|
||||
const conversationRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.doc(conversationId);
|
||||
|
||||
const name = composer.name || 'Untitled Conversation';
|
||||
const nameLower = name.toLowerCase();
|
||||
|
||||
// Simple relevance scoring
|
||||
let relevanceScore = 0;
|
||||
|
||||
// Check for project keywords in name
|
||||
for (const keyword of projectKeywords) {
|
||||
if (nameLower.includes(keyword)) {
|
||||
relevanceScore += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Penalize for exclude keywords
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (nameLower.includes(keyword)) {
|
||||
relevanceScore -= 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if name mentions files from this workspace
|
||||
if (workspaceFiles && Array.isArray(workspaceFiles)) {
|
||||
for (const file of workspaceFiles) {
|
||||
if (nameLower.includes(file.toLowerCase())) {
|
||||
relevanceScore += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count messages
|
||||
let messageCount = 0;
|
||||
if (composer.bubbles && Array.isArray(composer.bubbles)) {
|
||||
messageCount = composer.bubbles.length;
|
||||
}
|
||||
|
||||
const conversationData = {
|
||||
userId,
|
||||
projectId,
|
||||
conversationId,
|
||||
composerId: composer.composerId,
|
||||
name,
|
||||
createdAt: new Date(composer.createdAt).toISOString(),
|
||||
lastUpdatedAt: new Date(composer.lastUpdatedAt).toISOString(),
|
||||
unifiedMode: composer.unifiedMode || false,
|
||||
forceMode: composer.forceMode || false,
|
||||
workspacePath,
|
||||
githubUrl: githubUrl || null,
|
||||
importedAt: new Date().toISOString(),
|
||||
relevanceScore, // For filtering
|
||||
messageCount,
|
||||
metadata: {
|
||||
source: 'cursor-monitor-extension',
|
||||
composerType: composer.type,
|
||||
}
|
||||
};
|
||||
|
||||
// Write conversation document first
|
||||
await conversationRef.set(conversationData);
|
||||
|
||||
// Store messages in chunks to avoid Firestore batch limit (500 operations)
|
||||
if (composer.bubbles && Array.isArray(composer.bubbles)) {
|
||||
const BATCH_SIZE = 400; // Leave room for overhead
|
||||
|
||||
for (let i = 0; i < composer.bubbles.length; i += BATCH_SIZE) {
|
||||
const batch = adminDb.batch();
|
||||
const chunk = composer.bubbles.slice(i, i + BATCH_SIZE);
|
||||
|
||||
for (const bubble of chunk) {
|
||||
const messageRef = conversationRef
|
||||
.collection('messages')
|
||||
.doc(bubble.bubbleId);
|
||||
|
||||
batch.set(messageRef, {
|
||||
bubbleId: bubble.bubbleId,
|
||||
type: bubble.type, // 1 = user, 2 = AI
|
||||
role: bubble.type === 1 ? 'user' : bubble.type === 2 ? 'assistant' : 'unknown',
|
||||
text: bubble.text || '',
|
||||
createdAt: bubble.createdAt,
|
||||
requestId: bubble.requestId,
|
||||
attachedFiles: bubble.attachedFiles || []
|
||||
});
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
totalMessagesWritten += chunk.length;
|
||||
console.log(`✅ Wrote ${chunk.length} messages (${i + chunk.length}/${composer.bubbles.length}) for ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
conversationCount++;
|
||||
}
|
||||
|
||||
// Store workspace metadata for reference
|
||||
const workspaceMetaRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('workspace-meta');
|
||||
|
||||
await workspaceMetaRef.set({
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
workspaceFiles: workspaceFiles || [],
|
||||
totalGenerations: totalGenerations || 0,
|
||||
importedAt: new Date().toISOString(),
|
||||
lastBatchImportedAt: new Date().toISOString(),
|
||||
}, { merge: true });
|
||||
|
||||
console.log(`✅ Imported ${conversationCount} conversations to project ${projectId}`);
|
||||
|
||||
const workspaceFilesCount = conversations.workspaceFiles?.length || workspaceFiles?.length || 0;
|
||||
const generationsCount = conversations.totalGenerations || totalGenerations || 0;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
conversationCount,
|
||||
totalMessages: totalMessagesWritten,
|
||||
workspaceFilesCount,
|
||||
totalGenerations: generationsCount,
|
||||
message: `Successfully imported ${conversationCount} conversations with ${totalMessagesWritten} messages`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error importing Cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to import conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
// TEMPORARY: For debugging/testing only - no auth required
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing projectId' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all cursor conversations for this project
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const batch = adminDb.batch();
|
||||
|
||||
conversationsSnapshot.docs.forEach((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
|
||||
batch.delete(doc.ref);
|
||||
});
|
||||
|
||||
// Also delete the messages data document
|
||||
const messagesRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('messages');
|
||||
|
||||
batch.delete(messagesRef);
|
||||
|
||||
await batch.commit();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedCount: conversationsSnapshot.size,
|
||||
message: 'All cursor conversations cleared'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error clearing cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '30'); // 30 min default
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map((doc: FirebaseFirestore.QueryDocumentSnapshot) => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
ref: doc.ref,
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
relevanceScore: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Step 1: Group by date
|
||||
const conversationsByDate: Record<string, typeof conversations> = {};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const dateKey = conv.createdAt.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
if (!conversationsByDate[dateKey]) {
|
||||
conversationsByDate[dateKey] = [];
|
||||
}
|
||||
conversationsByDate[dateKey].push(conv);
|
||||
}
|
||||
|
||||
// Step 2: Within each date, create sessions based on time gaps
|
||||
const sessions: any[] = [];
|
||||
let sessionId = 0;
|
||||
|
||||
for (const [date, dayConversations] of Object.entries(conversationsByDate)) {
|
||||
let currentSession: any = null;
|
||||
|
||||
for (const conv of dayConversations) {
|
||||
if (!currentSession) {
|
||||
// Start first session of the day
|
||||
sessionId++;
|
||||
currentSession = {
|
||||
sessionId,
|
||||
date,
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv]
|
||||
};
|
||||
} else {
|
||||
// Check time gap from last conversation
|
||||
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
|
||||
const gapMinutes = gapMs / (1000 * 60);
|
||||
|
||||
if (gapMinutes <= sessionGapMinutes) {
|
||||
// Same session
|
||||
currentSession.conversations.push(conv);
|
||||
currentSession.endTime = conv.createdAt;
|
||||
} else {
|
||||
// New session - close current and start new
|
||||
sessions.push(currentSession);
|
||||
sessionId++;
|
||||
currentSession = {
|
||||
sessionId,
|
||||
date,
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last session of the day
|
||||
if (currentSession) {
|
||||
sessions.push(currentSession);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Analyze each session and determine project
|
||||
const projectKeywords = ['vibn', 'extension', 'collector', 'cursor-monitor'];
|
||||
const excludeKeywords = ['nhl', 'hockey', 'market', 'transaction'];
|
||||
|
||||
const analyzedSessions = sessions.map(session => {
|
||||
const allNames = session.conversations.map((c: any) => c.name.toLowerCase()).join(' ');
|
||||
|
||||
let projectTag = 'unknown';
|
||||
let confidence = 'low';
|
||||
|
||||
// Check for strong exclude signals
|
||||
for (const keyword of excludeKeywords) {
|
||||
if (allNames.includes(keyword)) {
|
||||
projectTag = 'other';
|
||||
confidence = 'high';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not excluded, check for vibn signals
|
||||
if (projectTag === 'unknown') {
|
||||
for (const keyword of projectKeywords) {
|
||||
if (allNames.includes(keyword)) {
|
||||
projectTag = 'vibn';
|
||||
confidence = 'high';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still unknown, check for generic "project" keyword
|
||||
if (projectTag === 'unknown' && allNames.includes('project')) {
|
||||
projectTag = 'vibn';
|
||||
confidence = 'medium';
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
projectTag,
|
||||
confidence,
|
||||
conversationCount: session.conversations.length
|
||||
};
|
||||
});
|
||||
|
||||
// Step 4: Update Firestore with session tags
|
||||
const batch = adminDb.batch();
|
||||
let updateCount = 0;
|
||||
|
||||
for (const session of analyzedSessions) {
|
||||
for (const conv of session.conversations) {
|
||||
batch.update(conv.ref, {
|
||||
sessionId: session.sessionId,
|
||||
sessionDate: session.date,
|
||||
sessionProject: session.projectTag,
|
||||
sessionConfidence: session.confidence
|
||||
});
|
||||
updateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
|
||||
// Return summary
|
||||
const summary = {
|
||||
totalConversations: conversations.length,
|
||||
totalSessions: sessions.length,
|
||||
sessionGapMinutes,
|
||||
projectBreakdown: {
|
||||
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').length,
|
||||
other: analyzedSessions.filter(s => s.projectTag === 'other').length,
|
||||
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').length
|
||||
},
|
||||
conversationBreakdown: {
|
||||
vibn: analyzedSessions.filter(s => s.projectTag === 'vibn').reduce((sum, s) => sum + s.conversationCount, 0),
|
||||
other: analyzedSessions.filter(s => s.projectTag === 'other').reduce((sum, s) => sum + s.conversationCount, 0),
|
||||
unknown: analyzedSessions.filter(s => s.projectTag === 'unknown').reduce((sum, s) => sum + s.conversationCount, 0)
|
||||
},
|
||||
sampleSessions: analyzedSessions.slice(0, 10).map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
date: s.date,
|
||||
conversationCount: s.conversationCount,
|
||||
projectTag: s.projectTag,
|
||||
confidence: s.confidence,
|
||||
conversationNames: s.conversations.slice(0, 3).map((c: any) => c.name)
|
||||
}))
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updatedConversations: updateCount,
|
||||
...summary
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error tagging sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to tag sessions', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
import { FieldValue } from 'firebase-admin/firestore';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const projectId = (body.projectId ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'projectId is required' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
const docRef = adminDb.collection('chat_conversations').doc(projectId);
|
||||
|
||||
await adminDb.runTransaction(async (tx) => {
|
||||
const snapshot = await tx.get(docRef);
|
||||
const existing = (snapshot.exists ? (snapshot.data()?.messages as unknown[]) : []) ?? [];
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const newMessages = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: '[debug] test user message',
|
||||
createdAt: now,
|
||||
},
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: '[debug] test assistant reply',
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
|
||||
tx.set(
|
||||
docRef,
|
||||
{
|
||||
projectId,
|
||||
messages: [...existing, ...newMessages],
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[debug/append-conversation] Failed to append messages', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to append debug conversation messages',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Debug API to check session links
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminAuth, getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const idToken = authHeader.split('Bearer ')[1];
|
||||
const adminAuth = getAdminAuth();
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
let userId: string;
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(idToken);
|
||||
userId = decodedToken.uid;
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all user's sessions
|
||||
const sessionsSnapshot = await adminDb
|
||||
.collection('sessions')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const linked: any[] = [];
|
||||
const unlinked: any[] = [];
|
||||
|
||||
sessionsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
const sessionInfo = {
|
||||
id: doc.id,
|
||||
workspaceName: data.workspaceName || 'Unknown',
|
||||
workspacePath: data.workspacePath,
|
||||
projectId: data.projectId,
|
||||
needsProjectAssociation: data.needsProjectAssociation,
|
||||
createdAt: data.createdAt?.toDate?.() || data.createdAt,
|
||||
};
|
||||
|
||||
if (data.projectId) {
|
||||
linked.push(sessionInfo);
|
||||
} else {
|
||||
unlinked.push(sessionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all user's projects
|
||||
const projectsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.where('userId', '==', userId)
|
||||
.get();
|
||||
|
||||
const projects = projectsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.data().productName || doc.data().name,
|
||||
workspacePath: doc.data().workspacePath,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
totalSessions: sessionsSnapshot.size,
|
||||
linkedSessions: linked.length,
|
||||
unlinkedSessions: unlinked.length,
|
||||
totalProjects: projects.length,
|
||||
},
|
||||
linked,
|
||||
unlinked,
|
||||
projects,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Debug check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to check links',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing projectId parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if project exists
|
||||
const projectDoc = await adminDb.collection('projects').doc(projectId).get();
|
||||
|
||||
if (!projectDoc.exists) {
|
||||
// List all projects to help debug
|
||||
const allProjectsSnapshot = await adminDb.collection('projects').limit(20).get();
|
||||
const allProjects = allProjectsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.data().name,
|
||||
userId: doc.data().userId,
|
||||
createdAt: doc.data().createdAt
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
exists: false,
|
||||
projectId,
|
||||
message: 'Project not found',
|
||||
availableProjects: allProjects
|
||||
});
|
||||
}
|
||||
|
||||
const projectData = projectDoc.data();
|
||||
|
||||
return NextResponse.json({
|
||||
exists: true,
|
||||
projectId,
|
||||
project: {
|
||||
name: projectData?.name,
|
||||
userId: projectData?.userId,
|
||||
createdAt: projectData?.createdAt,
|
||||
githubRepo: projectData?.githubRepo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking project:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check project', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get contextSources subcollection
|
||||
const contextSourcesRef = adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('contextSources');
|
||||
|
||||
const snapshot = await contextSourcesRef.get();
|
||||
|
||||
const sources = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
count: sources.length,
|
||||
sources,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/context-sources] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch context sources',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get workspace metadata
|
||||
const workspaceMetaDoc = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('workspace-meta')
|
||||
.get();
|
||||
|
||||
const workspaceMeta = workspaceMetaDoc.exists ? workspaceMetaDoc.data() : null;
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
// Analyze by score
|
||||
const negative = conversationsSnapshot.docs
|
||||
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
|
||||
.filter(c => c.score < 0)
|
||||
.sort((a, b) => a.score - b.score);
|
||||
|
||||
const positive = conversationsSnapshot.docs
|
||||
.map(doc => ({ name: doc.data().name, score: doc.data().relevanceScore || 0 }))
|
||||
.filter(c => c.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const neutral = conversationsSnapshot.docs
|
||||
.filter(doc => (doc.data().relevanceScore || 0) === 0)
|
||||
.length;
|
||||
|
||||
return NextResponse.json({
|
||||
workspacePath: workspaceMeta?.workspacePath,
|
||||
githubUrl: workspaceMeta?.githubUrl,
|
||||
workspaceFilesCount: workspaceMeta?.workspaceFiles?.length || 0,
|
||||
workspaceFilesSample: (workspaceMeta?.workspaceFiles || []).slice(0, 20),
|
||||
totalGenerations: workspaceMeta?.totalGenerations || 0,
|
||||
totalConversations: conversationsSnapshot.size,
|
||||
scoreBreakdown: {
|
||||
negative: negative.length,
|
||||
neutral,
|
||||
positive: positive.length
|
||||
},
|
||||
negativeScoreConversations: negative,
|
||||
topPositiveConversations: positive.slice(0, 10),
|
||||
sampleNeutralConversations: conversationsSnapshot.docs
|
||||
.filter(doc => (doc.data().relevanceScore || 0) === 0)
|
||||
.slice(0, 10)
|
||||
.map(doc => doc.data().name)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get 5 random conversations with content
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.limit(5)
|
||||
.get();
|
||||
|
||||
const samples = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
messageCount: data.messageCount || 0,
|
||||
promptCount: data.prompts?.length || 0,
|
||||
generationCount: data.generations?.length || 0,
|
||||
filesCount: data.files?.length || 0,
|
||||
sampleFiles: (data.files || []).slice(0, 3),
|
||||
samplePrompt: data.prompts?.[0]?.text?.substring(0, 100) || 'none',
|
||||
hasContent: !!(data.prompts?.length || data.generations?.length)
|
||||
};
|
||||
});
|
||||
|
||||
// Get overall stats
|
||||
const allConversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
let totalWithContent = 0;
|
||||
let totalWithFiles = 0;
|
||||
let totalMessages = 0;
|
||||
|
||||
allConversationsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
if (data.prompts?.length || data.generations?.length) {
|
||||
totalWithContent++;
|
||||
}
|
||||
if (data.files?.length) {
|
||||
totalWithFiles++;
|
||||
}
|
||||
totalMessages += data.messageCount || 0;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: allConversationsSnapshot.size,
|
||||
totalWithContent,
|
||||
totalWithFiles,
|
||||
totalMessages,
|
||||
samples
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching content sample:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sample', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get cursor conversations for this project
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'desc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data()
|
||||
}));
|
||||
|
||||
// Get the messages data
|
||||
const messagesDoc = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorData')
|
||||
.doc('messages')
|
||||
.get();
|
||||
|
||||
const messagesData = messagesDoc.exists ? messagesDoc.data() : null;
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
conversationCount: conversations.length,
|
||||
conversations,
|
||||
messagesData: messagesData ? {
|
||||
promptCount: messagesData.prompts?.length || 0,
|
||||
generationCount: messagesData.generations?.length || 0,
|
||||
importedAt: messagesData.importedAt
|
||||
} : null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching cursor conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const minScore = parseInt(request.nextUrl.searchParams.get('minScore') || '0');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs
|
||||
.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
relevanceScore: data.relevanceScore || 0,
|
||||
createdAt: data.createdAt,
|
||||
workspacePath: data.workspacePath
|
||||
};
|
||||
})
|
||||
.filter(c => c.relevanceScore >= minScore)
|
||||
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
||||
|
||||
// Group by score
|
||||
const scoreGroups: Record<number, number> = {};
|
||||
conversationsSnapshot.docs.forEach(doc => {
|
||||
const score = doc.data().relevanceScore || 0;
|
||||
scoreGroups[score] = (scoreGroups[score] || 0) + 1;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversationsSnapshot.size,
|
||||
filteredConversations: conversations.length,
|
||||
minScore,
|
||||
scoreDistribution: scoreGroups,
|
||||
conversations: conversations.slice(0, 50) // First 50
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching relevant conversations:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch conversations', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get 10 conversations with their dates
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'desc')
|
||||
.limit(10)
|
||||
.get();
|
||||
|
||||
const samples = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
createdAt: data.createdAt,
|
||||
workspacePath: data.workspacePath
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ samples });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching samples:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch samples', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
score: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Find NHL work sessions (negative scores clustered in time)
|
||||
const nhlConversations = conversations.filter(c => c.score < 0);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
nhlConversations: nhlConversations.length,
|
||||
nhlDates: nhlConversations.map(c => ({
|
||||
date: c.createdAt.toISOString().split('T')[0],
|
||||
name: c.name
|
||||
})),
|
||||
// Find if NHL conversations cluster
|
||||
nhlDateCounts: nhlConversations.reduce((acc: any, c) => {
|
||||
const date = c.createdAt.toISOString().split('T')[0];
|
||||
acc[date] = (acc[date] || 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing session summary:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const sessionGapMinutes = parseInt(request.nextUrl.searchParams.get('gap') || '120'); // 2 hours default
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations sorted by time
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.orderBy('createdAt', 'asc')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => {
|
||||
const data = doc.data();
|
||||
return {
|
||||
id: doc.id,
|
||||
name: data.name,
|
||||
createdAt: new Date(data.createdAt),
|
||||
relevanceScore: data.relevanceScore || 0
|
||||
};
|
||||
});
|
||||
|
||||
// Group into sessions based on time gaps
|
||||
const sessions: any[] = [];
|
||||
let currentSession: any = null;
|
||||
|
||||
for (const conv of conversations) {
|
||||
if (!currentSession) {
|
||||
// Start first session
|
||||
currentSession = {
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv],
|
||||
relevanceScores: [conv.relevanceScore]
|
||||
};
|
||||
} else {
|
||||
// Check time gap from last conversation
|
||||
const gapMs = conv.createdAt.getTime() - currentSession.endTime.getTime();
|
||||
const gapMinutes = gapMs / (1000 * 60);
|
||||
|
||||
if (gapMinutes <= sessionGapMinutes) {
|
||||
// Same session
|
||||
currentSession.conversations.push(conv);
|
||||
currentSession.relevanceScores.push(conv.relevanceScore);
|
||||
currentSession.endTime = conv.createdAt;
|
||||
} else {
|
||||
// New session - close current and start new
|
||||
sessions.push(currentSession);
|
||||
currentSession = {
|
||||
startTime: conv.createdAt,
|
||||
endTime: conv.createdAt,
|
||||
conversations: [conv],
|
||||
relevanceScores: [conv.relevanceScore]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add last session
|
||||
if (currentSession) {
|
||||
sessions.push(currentSession);
|
||||
}
|
||||
|
||||
// Analyze each session
|
||||
const analyzedSessions = sessions.map((session, idx) => {
|
||||
const durationMinutes = Math.round((session.endTime.getTime() - session.startTime.getTime()) / (1000 * 60));
|
||||
|
||||
// Calculate session relevance score (average of all conversations)
|
||||
const avgScore = session.relevanceScores.reduce((a: number, b: number) => a + b, 0) / session.relevanceScores.length;
|
||||
|
||||
// Count negative/positive conversations
|
||||
const negative = session.relevanceScores.filter((s: number) => s < 0).length;
|
||||
const positive = session.relevanceScores.filter((s: number) => s > 0).length;
|
||||
const neutral = session.relevanceScores.filter((s: number) => s === 0).length;
|
||||
|
||||
// Determine likely project based on majority
|
||||
let likelyProject = 'unknown';
|
||||
if (negative > positive && negative > neutral) {
|
||||
likelyProject = 'other (NHL/market)';
|
||||
} else if (positive > negative && positive > neutral) {
|
||||
likelyProject = 'vibn (likely)';
|
||||
} else if (positive > 0 || avgScore > 0) {
|
||||
likelyProject = 'vibn (mixed)';
|
||||
} else {
|
||||
likelyProject = 'unclear';
|
||||
}
|
||||
|
||||
return {
|
||||
sessionNumber: idx + 1,
|
||||
startTime: session.startTime.toISOString(),
|
||||
endTime: session.endTime.toISOString(),
|
||||
durationMinutes,
|
||||
conversationCount: session.conversations.length,
|
||||
avgRelevanceScore: Math.round(avgScore * 100) / 100,
|
||||
scoreBreakdown: { negative, neutral, positive },
|
||||
likelyProject,
|
||||
conversationNames: session.conversations.slice(0, 5).map((c: any) => c.name)
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
totalSessions: sessions.length,
|
||||
sessionGapMinutes,
|
||||
sessions: analyzedSessions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to analyze sessions', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
|
||||
|
||||
// Find date range
|
||||
const dates = conversations
|
||||
.filter(c => c.createdAt)
|
||||
.map(c => new Date(c.createdAt))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
if (dates.length === 0) {
|
||||
return NextResponse.json({ error: 'No conversations with dates found' });
|
||||
}
|
||||
|
||||
const earliest = dates[0];
|
||||
const latest = dates[dates.length - 1];
|
||||
const span = Math.floor((latest.getTime() - earliest.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Find the actual conversation names for earliest and latest
|
||||
const earliestConv = conversations.find(c =>
|
||||
new Date(c.createdAt).getTime() === earliest.getTime()
|
||||
);
|
||||
const latestConv = conversations.find(c =>
|
||||
new Date(c.createdAt).getTime() === latest.getTime()
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
dateRange: {
|
||||
earliest: earliest.toISOString(),
|
||||
latest: latest.toISOString(),
|
||||
spanDays: span
|
||||
},
|
||||
oldestConversation: {
|
||||
name: earliestConv?.name || 'Unknown',
|
||||
date: earliest.toISOString()
|
||||
},
|
||||
newestConversation: {
|
||||
name: latestConv?.name || 'Unknown',
|
||||
date: latest.toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching cursor stats:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch stats', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all unknown conversations grouped by session
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.where('sessionProject', '==', 'unknown')
|
||||
.get();
|
||||
|
||||
const sessionMap: Record<number, any[]> = {};
|
||||
|
||||
conversationsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
const sessionId = data.sessionId;
|
||||
|
||||
if (!sessionMap[sessionId]) {
|
||||
sessionMap[sessionId] = [];
|
||||
}
|
||||
|
||||
sessionMap[sessionId].push({
|
||||
name: data.name,
|
||||
date: data.sessionDate,
|
||||
createdAt: data.createdAt
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and take sample
|
||||
const sessions = Object.entries(sessionMap).map(([sessionId, conversations]) => ({
|
||||
sessionId: parseInt(sessionId),
|
||||
date: conversations[0].date,
|
||||
conversationCount: conversations.length,
|
||||
conversationNames: conversations.map(c => c.name)
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
totalUnknownSessions: sessions.length,
|
||||
totalUnknownConversations: conversationsSnapshot.size,
|
||||
sample: sessions.slice(0, 30)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching unknown sessions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get all conversations
|
||||
const conversationsSnapshot = await adminDb
|
||||
.collection('projects')
|
||||
.doc(projectId)
|
||||
.collection('cursorConversations')
|
||||
.get();
|
||||
|
||||
const conversations = conversationsSnapshot.docs.map(doc => doc.data());
|
||||
|
||||
// Group by workspace path
|
||||
const workspaceGroups: Record<string, number> = {};
|
||||
const githubGroups: Record<string, number> = {};
|
||||
|
||||
conversations.forEach(conv => {
|
||||
const workspace = conv.workspacePath || 'unknown';
|
||||
const github = conv.githubUrl || 'none';
|
||||
|
||||
workspaceGroups[workspace] = (workspaceGroups[workspace] || 0) + 1;
|
||||
githubGroups[github] = (githubGroups[github] || 0) + 1;
|
||||
});
|
||||
|
||||
// Sort by count
|
||||
const workspaceList = Object.entries(workspaceGroups)
|
||||
.map(([path, count]) => ({ path, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const githubList = Object.entries(githubGroups)
|
||||
.map(([url, count]) => ({ url, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return NextResponse.json({
|
||||
totalConversations: conversations.length,
|
||||
uniqueWorkspaces: workspaceList.length,
|
||||
uniqueRepos: githubList.length,
|
||||
workspaces: workspaceList,
|
||||
repos: githubList
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace breakdown:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch breakdown', details: error instanceof Error ? error.message : String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
vibn-frontend/app/api/debug/env/route.ts
vendored
18
vibn-frontend/app/api/debug/env/route.ts
vendored
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
firebaseProjectId: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
firebaseClientEmail: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
|
||||
firebasePrivateKey: process.env.FIREBASE_PRIVATE_KEY ? 'SET (length: ' + process.env.FIREBASE_PRIVATE_KEY.length + ')' : 'NOT SET',
|
||||
|
||||
publicApiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ? 'SET' : 'NOT SET',
|
||||
publicAuthDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ? 'SET' : 'NOT SET',
|
||||
publicProjectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
|
||||
tip: 'If any Firebase vars show NOT SET, restart your dev server after updating .env.local'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const adminDb = getAdminDb();
|
||||
const snapshot = await adminDb.collection('projects').limit(1).get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No projects found' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const doc = snapshot.docs[0];
|
||||
return NextResponse.json({
|
||||
id: doc.id,
|
||||
data: doc.data(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/first-project] Failed to load project', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to load project',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = url.searchParams.get('projectId');
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Get knowledge_items collection
|
||||
const knowledgeItemsRef = adminDb.collection('knowledge_items');
|
||||
const snapshot = await knowledgeItemsRef
|
||||
.where('projectId', '==', projectId)
|
||||
.limit(20)
|
||||
.get();
|
||||
|
||||
const items = snapshot.docs.map(doc => ({
|
||||
id: doc.id,
|
||||
...doc.data(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
projectId,
|
||||
count: items.length,
|
||||
items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[debug/knowledge-items] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to fetch knowledge items',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAdminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const projectId = (url.searchParams.get('projectId') ?? '').trim();
|
||||
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
// Use a simple filter query without orderBy to avoid requiring a composite index.
|
||||
const snapshot = await adminDb
|
||||
.collection('knowledge_items')
|
||||
.where('projectId', '==', projectId)
|
||||
.get();
|
||||
|
||||
const items = snapshot.docs.map((doc) => doc.data());
|
||||
|
||||
return NextResponse.json({ count: items.length, items });
|
||||
} catch (error) {
|
||||
console.error('[debug/knowledge] Failed to list knowledge items', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to list knowledge items',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,61 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { adminAuth, adminDb } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const diagnostics: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: {},
|
||||
firebase: {},
|
||||
token: {},
|
||||
};
|
||||
|
||||
try {
|
||||
// Check environment variables
|
||||
diagnostics.environment = {
|
||||
FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID ? 'SET' : 'NOT SET',
|
||||
FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL ? 'SET' : 'NOT SET',
|
||||
FIREBASE_PRIVATE_KEY: process.env.FIREBASE_PRIVATE_KEY ? `SET (${process.env.FIREBASE_PRIVATE_KEY.length} chars)` : 'NOT SET',
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || 'NOT SET',
|
||||
};
|
||||
|
||||
// Test Firebase Admin
|
||||
try {
|
||||
const testDoc = await adminDb.collection('test').doc('diagnostic').get();
|
||||
diagnostics.firebase.adminDb = 'OK - Can access Firestore';
|
||||
diagnostics.firebase.adminAuth = 'OK - Auth service initialized';
|
||||
} catch (error: any) {
|
||||
diagnostics.firebase.error = error.message;
|
||||
}
|
||||
|
||||
// Try to verify a token if provided
|
||||
const authHeader = request.headers.get('authorization');
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
diagnostics.token.received = true;
|
||||
diagnostics.token.length = token.length;
|
||||
|
||||
try {
|
||||
const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
diagnostics.token.verification = 'SUCCESS';
|
||||
diagnostics.token.uid = decodedToken.uid;
|
||||
diagnostics.token.email = decodedToken.email;
|
||||
} catch (error: any) {
|
||||
diagnostics.token.verification = 'FAILED';
|
||||
diagnostics.token.error = error.message;
|
||||
diagnostics.token.errorCode = error.code;
|
||||
}
|
||||
} else {
|
||||
diagnostics.token.received = false;
|
||||
diagnostics.token.note = 'No token provided - add Authorization: Bearer <token> header to test';
|
||||
}
|
||||
|
||||
return NextResponse.json(diagnostics, { status: 200 });
|
||||
} catch (error: any) {
|
||||
diagnostics.criticalError = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
return NextResponse.json(diagnostics, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { adminDb, adminAuth } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test 1: Check if Firebase Admin is initialized
|
||||
if (!adminDb) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Firebase Admin not initialized' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Test 2: Try to access Firestore (this will verify credentials)
|
||||
const testCollection = adminDb.collection('_healthcheck');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Write a test document
|
||||
const docRef = await testCollection.add({
|
||||
message: 'Firebase connection test',
|
||||
timestamp: timestamp,
|
||||
});
|
||||
|
||||
// Read it back
|
||||
const doc = await docRef.get();
|
||||
const data = doc.data();
|
||||
|
||||
// Clean up
|
||||
await docRef.delete();
|
||||
|
||||
// Test 3: Check Auth is working
|
||||
const authCheck = adminAuth ? 'OK' : 'Failed';
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Firebase is connected successfully! 🎉',
|
||||
tests: {
|
||||
adminInit: 'OK',
|
||||
firestoreWrite: 'OK',
|
||||
firestoreRead: 'OK',
|
||||
authInit: authCheck,
|
||||
},
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
testData: data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Firebase test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Firebase connection failed',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
tip: 'Check your .env.local file for correct FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
isCoolifyInfraOperational,
|
||||
runCoolifyInfraHealthProbe,
|
||||
} from '@/lib/server/infra-coolify-health';
|
||||
} from "@/lib/server/infra-coolify-health";
|
||||
import { timingSafeStringEq } from "@/lib/server/timing-safe";
|
||||
|
||||
/**
|
||||
* Authenticated infrastructure probe for Coolify API + SSH→Docker.
|
||||
@@ -23,20 +24,22 @@ export async function GET(req: NextRequest) {
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
'INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.',
|
||||
"INFRA_HEALTH_SECRET is not set — configure it on vibn-frontend to enable this probe.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const auth = req.headers.get('authorization');
|
||||
const bearer =
|
||||
auth?.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const headerSecret = req.headers.get('x-vibn-infra-secret')?.trim() ?? '';
|
||||
const auth = req.headers.get("authorization");
|
||||
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
const headerSecret = req.headers.get("x-vibn-infra-secret")?.trim() ?? "";
|
||||
const token = bearer || headerSecret;
|
||||
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ ok: false, error: 'Unauthorized' }, { status: 401 });
|
||||
if (!token || !timingSafeStringEq(secret, token)) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Unauthorized" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
85
vibn-frontend/app/api/invites/[token]/route.ts
Normal file
85
vibn-frontend/app/api/invites/[token]/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* GET /api/invites/[token] — validate (public, used by /auth page)
|
||||
* POST /api/invites/[token]/redeem — consume (called on sign-up completion)
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
import { authSession } from "@/lib/auth/session-server";
|
||||
|
||||
interface InviteRow {
|
||||
token: string;
|
||||
email: string | null;
|
||||
max_uses: number;
|
||||
use_count: number;
|
||||
expires_at: string | null;
|
||||
redeemed_by: string[];
|
||||
}
|
||||
|
||||
async function validateInvite(
|
||||
token: string,
|
||||
): Promise<{ valid: boolean; reason?: string; row?: InviteRow }> {
|
||||
const row = await queryOne<InviteRow>(
|
||||
`SELECT token, email, max_uses, use_count, expires_at, redeemed_by
|
||||
FROM invites WHERE token = $1`,
|
||||
[token],
|
||||
);
|
||||
if (!row) return { valid: false, reason: "Token not found" };
|
||||
if (row.use_count >= row.max_uses)
|
||||
return { valid: false, reason: "Token already fully redeemed" };
|
||||
if (row.expires_at && new Date(row.expires_at) < new Date())
|
||||
return { valid: false, reason: "Token expired" };
|
||||
return { valid: true, row };
|
||||
}
|
||||
|
||||
/** GET /api/invites/[token] — check if a token is valid (used by auth page UI) */
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const { valid, reason, row } = await validateInvite(token);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ valid: false, reason }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
email: row!.email ?? null,
|
||||
usesRemaining: row!.max_uses - row!.use_count,
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/invites/[token] — redeem (call after a user signs up) */
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const session = await authSession();
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { token } = await params;
|
||||
const { valid, reason, row } = await validateInvite(token);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ ok: false, reason }, { status: 400 });
|
||||
}
|
||||
const email = session.user.email;
|
||||
if (row!.email && row!.email !== email) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, reason: "Token is for a different email address" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (row!.redeemed_by.includes(email)) {
|
||||
return NextResponse.json(
|
||||
{ ok: true, alreadyRedeemed: true },
|
||||
);
|
||||
}
|
||||
await query(
|
||||
`UPDATE invites
|
||||
SET use_count = use_count + 1,
|
||||
redeemed_by = array_append(redeemed_by, $2)
|
||||
WHERE token = $1`,
|
||||
[token, email],
|
||||
);
|
||||
return NextResponse.json({ ok: true, redeemed: true });
|
||||
}
|
||||
90
vibn-frontend/app/api/invites/route.ts
Normal file
90
vibn-frontend/app/api/invites/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* POST /api/invites
|
||||
*
|
||||
* Admin-only. Creates an invite token and returns the invite URL.
|
||||
* Closes BETA_LAUNCH_PLAN P4.8.
|
||||
*
|
||||
* Body: { email?: string, note?: string, maxUses?: number }
|
||||
* Auth: Bearer ADMIN_MIGRATE_SECRET
|
||||
*
|
||||
* curl -X POST https://vibnai.com/api/invites \
|
||||
* -H "x-admin-secret: $ADMIN_MIGRATE_SECRET" \
|
||||
* -d '{"email":"friend@example.com","note":"beta tester"}'
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withAdminSecret } from "@/lib/server/api-handler";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
note TEXT,
|
||||
max_uses INT NOT NULL DEFAULT 1,
|
||||
use_count INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
redeemed_by TEXT[] NOT NULL DEFAULT '{}'
|
||||
)
|
||||
`);
|
||||
await query(`CREATE INDEX IF NOT EXISTS invites_email_idx ON invites (email) WHERE email IS NOT NULL`);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
export const POST = withAdminSecret(
|
||||
async (request) => {
|
||||
await ensureTable();
|
||||
const body = await request.json().catch(() => ({})) as {
|
||||
email?: string;
|
||||
note?: string;
|
||||
maxUses?: number;
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
const token = randomBytes(20).toString("hex");
|
||||
const maxUses = Math.max(1, Math.min(100, body.maxUses ?? 1));
|
||||
const expiresAt = body.expiresInDays
|
||||
? new Date(Date.now() + body.expiresInDays * 86_400_000).toISOString()
|
||||
: null;
|
||||
|
||||
await query(
|
||||
`INSERT INTO invites (token, email, note, max_uses, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[token, body.email ?? null, body.note ?? null, maxUses, expiresAt],
|
||||
);
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_APP_URL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
"https://vibnai.com";
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
token,
|
||||
inviteUrl: `${baseUrl}/auth?invite=${token}`,
|
||||
email: body.email ?? null,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
});
|
||||
},
|
||||
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
||||
);
|
||||
|
||||
export const GET = withAdminSecret(
|
||||
async (request) => {
|
||||
await ensureTable();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = Math.min(200, parseInt(searchParams.get("limit") ?? "50", 10));
|
||||
const rows = await query(
|
||||
`SELECT token, email, note, max_uses, use_count, created_at, expires_at, redeemed_by
|
||||
FROM invites ORDER BY created_at DESC LIMIT $1`,
|
||||
[limit],
|
||||
);
|
||||
return NextResponse.json({ invites: rows });
|
||||
},
|
||||
{ secretEnvVar: "ADMIN_MIGRATE_SECRET", altHeader: "x-admin-secret" },
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DELETE /api/projects/[projectId]/secrets/[key]
|
||||
* GET /api/projects/[projectId]/secrets/[key] — reveal (decrypted)
|
||||
*
|
||||
* Reveal is intentionally project-scoped (user must own the project).
|
||||
* Never log the plaintext value.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { decryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export const DELETE = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
await query(
|
||||
`DELETE FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
log.info("project secret deleted", {
|
||||
route: "api.projects.secrets.delete",
|
||||
projectId: project.id,
|
||||
key,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = withTenantProject(
|
||||
async (_req, ctx, { project }) => {
|
||||
const { key } = await (ctx.params as Promise<{ projectId: string; key: string }>);
|
||||
const row = await queryOne<{ value_enc: string }>(
|
||||
`SELECT value_enc FROM fs_project_secrets WHERE project_id = $1 AND key = $2`,
|
||||
[project.id, key],
|
||||
);
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Secret not found" }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const value = decryptSecret(row.value_enc);
|
||||
return NextResponse.json({ key, value });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Decryption failed — VIBN_SECRETS_KEY may have rotated" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
91
vibn-frontend/app/api/projects/[projectId]/secrets/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Project-level encrypted secret scratchpad.
|
||||
* Closes BETA_LAUNCH_PLAN P6.D2.
|
||||
*
|
||||
* GET /api/projects/[projectId]/secrets — list key names only (never values)
|
||||
* POST /api/projects/[projectId]/secrets — set/update a secret { key, value }
|
||||
*
|
||||
* Values are encrypted at-rest with AES-256-GCM via the existing
|
||||
* `lib/auth/secret-box.ts` (same envelope used for workspace API keys and
|
||||
* Gitea bot PATs). The plaintext value is NEVER returned by the list route.
|
||||
* Use a dedicated GET /secrets/[key] route (below) if you need to surface
|
||||
* it to the AI.
|
||||
*
|
||||
* Table created lazily on first write.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { encryptSecret } from "@/lib/auth/secret-box";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
let tableReady = false;
|
||||
async function ensureTable() {
|
||||
if (tableReady) return;
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS fs_project_secrets (
|
||||
project_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value_enc TEXT NOT NULL, -- AES-256-GCM encrypted, base64
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, key)
|
||||
)
|
||||
`);
|
||||
await query(
|
||||
`CREATE INDEX IF NOT EXISTS fs_project_secrets_project_idx ON fs_project_secrets (project_id)`,
|
||||
);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
/** GET — returns key names only, never values. */
|
||||
export const GET = withTenantProject(
|
||||
async (_req, _ctx, { project }) => {
|
||||
await ensureTable();
|
||||
const rows = await query<{ key: string; updated_at: string }>(
|
||||
`SELECT key, updated_at FROM fs_project_secrets
|
||||
WHERE project_id = $1 ORDER BY key`,
|
||||
[project.id],
|
||||
);
|
||||
return NextResponse.json({ secrets: rows });
|
||||
},
|
||||
);
|
||||
|
||||
/** POST — upsert a secret. Body: { key: string, value: string } */
|
||||
export const POST = withTenantProject(
|
||||
async (req, _ctx, { project }) => {
|
||||
const body = await req.json().catch(() => ({})) as {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
if (!body.key || typeof body.key !== "string") {
|
||||
return NextResponse.json({ error: "key is required" }, { status: 400 });
|
||||
}
|
||||
if (typeof body.value !== "string") {
|
||||
return NextResponse.json({ error: "value is required" }, { status: 400 });
|
||||
}
|
||||
if (body.key.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "key must be ≤200 chars" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await ensureTable();
|
||||
const valueEnc = encryptSecret(body.value);
|
||||
await query(
|
||||
`INSERT INTO fs_project_secrets (project_id, key, value_enc)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, key) DO UPDATE SET
|
||||
value_enc = EXCLUDED.value_enc,
|
||||
updated_at = NOW()`,
|
||||
[project.id, body.key.trim(), valueEnc],
|
||||
);
|
||||
log.info("project secret upserted", {
|
||||
route: "api.projects.secrets.post",
|
||||
projectId: project.id,
|
||||
key: body.key,
|
||||
});
|
||||
return NextResponse.json({ ok: true, key: body.key });
|
||||
},
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Companion endpoint to /sentry-example-page. Throws on every call
|
||||
* so the "Throw server error" button has something real to fail
|
||||
* against. The thrown error propagates to instrumentation.ts'
|
||||
* onRequestError hook and lands in Sentry as a server-side issue.
|
||||
*/
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
class SentryExampleApiError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SentryExampleApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
throw new SentryExampleApiError(
|
||||
"Sentry test (API route) — vibn-ai",
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { auth } from '@/lib/firebase/config';
|
||||
import { adminAuth } from '@/lib/firebase/admin';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get current user from client-side auth
|
||||
const user = auth.currentUser;
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get ID token
|
||||
const token = await user.getIdToken();
|
||||
|
||||
console.log('Token length:', token.length);
|
||||
console.log('User UID:', user.uid);
|
||||
|
||||
// Try to verify it with Admin SDK
|
||||
const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
clientUid: user.uid,
|
||||
decodedUid: decodedToken.uid,
|
||||
match: user.uid === decodedToken.uid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Token verification failed',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,90 @@
|
||||
/**
|
||||
* POST /api/webhooks/coolify?projectId={projectId}
|
||||
*
|
||||
* Receives deployment status events from Coolify.
|
||||
* Updates the project's contextSnapshot.lastDeployment in Postgres.
|
||||
* Receives deployment status events from Coolify and updates the project's
|
||||
* contextSnapshot.lastDeployment in Postgres.
|
||||
*
|
||||
* Closes S-06: signature verification was missing. Coolify (≥4.0.0-beta.300)
|
||||
* signs every webhook with HMAC-SHA256 of the raw body using the per-app
|
||||
* `webhook_secret`. We now reject unsigned / mismatched requests.
|
||||
*
|
||||
* Configure: in Coolify, set the webhook target to
|
||||
* https://vibnai.com/api/webhooks/coolify?projectId={projectId}
|
||||
* AND set the per-app webhook secret to match `COOLIFY_WEBHOOK_SECRET`.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { query } from '@/lib/db-postgres';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import { verifyCoolifySignature } from "@/lib/server/coolify-webhook";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
const COOLIFY_WEBHOOK_SECRET = process.env.COOLIFY_WEBHOOK_SECRET ?? "";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const projectId = request.nextUrl.searchParams.get('projectId');
|
||||
const projectId = request.nextUrl.searchParams.get("projectId");
|
||||
if (!projectId) {
|
||||
return NextResponse.json({ error: 'Missing projectId' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Missing projectId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let payload: any;
|
||||
if (!COOLIFY_WEBHOOK_SECRET) {
|
||||
log.error("coolify webhook: COOLIFY_WEBHOOK_SECRET not configured", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook receiver not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// We need the RAW body for HMAC verification — `.json()` would
|
||||
// re-serialize and could change byte order. Read text first, then parse.
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get("x-coolify-signature-256");
|
||||
|
||||
const ok = await verifyCoolifySignature(
|
||||
rawBody,
|
||||
signature,
|
||||
COOLIFY_WEBHOOK_SECRET,
|
||||
);
|
||||
if (!ok) {
|
||||
log.warn("coolify webhook: invalid signature", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
hasHeader: !!signature,
|
||||
});
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = await request.json();
|
||||
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rows = await query<{ id: string; data: any }>(
|
||||
const rows = await query<{ id: string; data: Record<string, unknown> }>(
|
||||
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId]
|
||||
[projectId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ error: 'Project not found' }, { status: 404 });
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const project = rows[0];
|
||||
const existingSnapshot = project.data?.contextSnapshot ?? {};
|
||||
const existingSnapshot =
|
||||
(project.data?.contextSnapshot as Record<string, unknown>) ?? {};
|
||||
|
||||
// Coolify sends status events like: queued, in_progress, finished, failed, cancelled
|
||||
const status = payload.status ?? payload.data?.status ?? 'unknown';
|
||||
const applicationUuid = payload.application_uuid ?? payload.data?.application_uuid;
|
||||
const deploymentUuid = payload.deployment_uuid ?? payload.data?.deployment_uuid;
|
||||
const url = payload.fqdn ?? payload.data?.fqdn ?? null;
|
||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||
const status = (payload.status ?? data.status ?? "unknown") as string;
|
||||
const applicationUuid = (payload.application_uuid ??
|
||||
data.application_uuid) as string | undefined;
|
||||
const deploymentUuid = (payload.deployment_uuid ?? data.deployment_uuid) as
|
||||
| string
|
||||
| undefined;
|
||||
const url = (payload.fqdn ?? data.fqdn ?? null) as string | null;
|
||||
|
||||
const newSnapshot = {
|
||||
...existingSnapshot,
|
||||
@@ -51,12 +98,18 @@ export async function POST(request: NextRequest) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await query(`
|
||||
UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
|
||||
WHERE id = $2
|
||||
`, [JSON.stringify(newSnapshot), projectId]);
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = jsonb_set(data, '{contextSnapshot}', $1::jsonb)
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(newSnapshot), projectId],
|
||||
);
|
||||
|
||||
console.log(`[webhook/coolify] deploy ${status} for project ${projectId}`);
|
||||
log.info("coolify webhook received", {
|
||||
route: "api.webhooks.coolify",
|
||||
projectId,
|
||||
status,
|
||||
deploymentUuid,
|
||||
});
|
||||
return NextResponse.json({ ok: true, status, projectId });
|
||||
}
|
||||
|
||||
@@ -1,42 +1,65 @@
|
||||
/**
|
||||
* GET /api/work-completed?projectId=<uuid>&limit=<n>
|
||||
*
|
||||
* Returns the work_completed rows for a project the caller owns.
|
||||
*
|
||||
* Closes S-05: this route used to accept any projectId off the query string
|
||||
* with no auth and silently fell back to `projectId = 1` on missing input.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db-postgres";
|
||||
import type { WorkCompleted } from "@/lib/types";
|
||||
import { withTenantProject } from "@/lib/server/api-handler";
|
||||
import { log } from "@/lib/server/logger";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
export const GET = withTenantProject(
|
||||
async (request) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const projectId = searchParams.get("projectId");
|
||||
const limit = searchParams.get("limit") || "20";
|
||||
|
||||
const workItems = await query<WorkCompleted>(
|
||||
`SELECT
|
||||
wc.*,
|
||||
s.session_id,
|
||||
s.primary_ai_model,
|
||||
s.duration_minutes
|
||||
FROM work_completed wc
|
||||
LEFT JOIN sessions s ON wc.session_id = s.id
|
||||
WHERE wc.project_id = $1
|
||||
ORDER BY wc.completed_at DESC
|
||||
LIMIT $2`,
|
||||
[projectId || 1, limit],
|
||||
if (!projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "projectId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const limit = Math.min(
|
||||
200,
|
||||
Math.max(1, parseInt(searchParams.get("limit") ?? "20", 10) || 20),
|
||||
);
|
||||
|
||||
// Parse JSON fields
|
||||
const parsedWork = workItems.map((item) => ({
|
||||
...item,
|
||||
files_modified:
|
||||
typeof item.files_modified === "string"
|
||||
? JSON.parse(item.files_modified as any)
|
||||
: item.files_modified,
|
||||
}));
|
||||
|
||||
return NextResponse.json(parsedWork);
|
||||
} catch (error) {
|
||||
console.error("Error fetching work completed:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch work completed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const workItems = await query<WorkCompleted>(
|
||||
`SELECT
|
||||
wc.*,
|
||||
s.session_id,
|
||||
s.primary_ai_model,
|
||||
s.duration_minutes
|
||||
FROM work_completed wc
|
||||
LEFT JOIN sessions s ON wc.session_id = s.id
|
||||
WHERE wc.project_id = $1
|
||||
ORDER BY wc.completed_at DESC
|
||||
LIMIT $2`,
|
||||
[projectId, limit],
|
||||
);
|
||||
const parsedWork = workItems.map((item) => ({
|
||||
...item,
|
||||
files_modified:
|
||||
typeof item.files_modified === "string"
|
||||
? JSON.parse(item.files_modified)
|
||||
: item.files_modified,
|
||||
}));
|
||||
return NextResponse.json(parsedWork);
|
||||
} catch (err) {
|
||||
log.error("work-completed: db error", {
|
||||
route: "api.work-completed",
|
||||
projectId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch work completed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
},
|
||||
{ source: "search", paramName: "projectId" },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user